diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMDecryptionTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMDecryptionTest.kt new file mode 100644 index 0000000000..ed80e2f775 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMDecryptionTest.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM +import junit.framework.TestCase.assertEquals +import okhttp3.Request +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DMDecryptionTest { + val okHttp = HttpClientManager.getHttpClient(false) + + val url = "https://cdn.satellite.earth/812fd4cf9d4d4b59c141ecd6a6c08c7571b5872237ad6477916cb2d119b5cacd" + val cipher = + AESGCM( + "7b184b3849e161027bab1aac9f2d96eb22bfe02c8dc34cad5d2cc436a64f191d".hexToByteArray(), + "3936923383652e3f0d72bfd40568b435".hexToByteArray(), + ) + val decryptedSize = 1277122 + val expectedMimeType = "video/mp4" + + @Test + fun runDownloadAndDecryptVideo() { + HttpClientManager.addCipherToCache(url, cipher, "video/mp4") + + val request = + Request + .Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .get() + .build() + + okHttp.newCall(request).execute().use { + assertEquals(decryptedSize, it.body.bytes().size) + assertEquals(expectedMimeType, it.body.contentType().toString()) + } + } +} diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index fe7f8b9174..bf76e2ab60 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -34,9 +34,9 @@ import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.utils.sha256.sha256 import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail import kotlinx.coroutines.CoroutineScope @@ -81,7 +81,7 @@ class ImageUploadTesting { private suspend fun testBlossom(server: ServerName) { val paylod = getBitmap() - val initialHash = CryptoUtils.sha256(paylod).toHexKey() + val initialHash = sha256(paylod).toHexKey() val inputStream = paylod.inputStream() val result = BlossomUploader() @@ -111,7 +111,7 @@ class ImageUploadTesting { return } - val downloadedHash = CryptoUtils.sha256(imageData).toHexKey() + val downloadedHash = sha256(imageData).toHexKey() assertEquals(initialHash, downloadedHash) } diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt index 915268552b..956ae15ad9 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt @@ -23,10 +23,11 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder -import com.vitorpamplona.quartz.nip01Core.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip03Timestamp.ots.OpenTimestamps import junit.framework.TestCase.assertEquals import org.junit.Assert @@ -46,27 +47,27 @@ class OkHttpOtsTest { @Before fun setup() { - OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(forceProxy = { false }), OkHttpCalendarBuilder(forceProxy = { false })) + OtsResolver.ots = OpenTimestamps(OkHttpBlockstreamExplorer(forceProxy = { false }), OkHttpCalendarBuilder(forceProxy = { false })) } @Test fun verifyNostrEvent() { val ots = EventMapper.fromJson(otsEvent) as OtsEvent - println(ots.info()) + println(OtsResolver.info(ots.otsByteArray())) assertEquals(1707688818L, ots.verify()) } @Test fun verifyNostrEvent2() { val ots = EventMapper.fromJson(otsEvent2) as OtsEvent - println(ots.info()) + println(OtsResolver.info(ots.otsByteArray())) assertEquals(1706322179L, ots.verify()) } @Test fun verifyNostrPendingEvent() { val ots = EventMapper.fromJson(otsPendingEvent) as OtsEvent - println(ots.info()) + println(OtsResolver.info(ots.otsByteArray())) assertEquals(null, ots.verify()) } @@ -79,7 +80,7 @@ class OkHttpOtsTest { val otsFile = OtsEvent.stamp(otsEvent2Digest) - OtsEvent.create(otsEvent2Digest, otsFile, signer) { + signer.sign(OtsEvent.build(otsEvent2Digest, otsFile)) { ots = it countDownLatch.countDown() } @@ -87,7 +88,7 @@ class OkHttpOtsTest { Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) println(ots!!.toJson()) - println(ots!!.info()) + println(OtsResolver.info(ots!!.otsByteArray())) // Should not be valid because we need to wait for confirmations assertEquals(null, ots!!.verify()) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt similarity index 99% rename from amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt rename to amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 89ad1d1423..215e089cdb 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -26,11 +26,11 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hasValidSignature +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.verify import junit.framework.TestCase import junit.framework.TestCase.assertEquals import kotlinx.coroutines.CoroutineScope @@ -42,7 +42,7 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class ThreadAssemblerTest { +class ThreadDualAxisChartAssemblerTest { val db = """ [ @@ -125,7 +125,7 @@ class ThreadAssemblerTest { var counter = 0 eventArray.forEach { - TestCase.assertTrue("${it.id} failed signature check", it.hasValidSignature()) + TestCase.assertTrue("${it.id} failed signature check", it.verify()) LocalCache.verifyAndConsume(it, null) counter++ } diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index 8babb8e310..263361fb8d 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -27,8 +27,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting -import com.vitorpamplona.quartz.nip01Core.UserMetadata -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import org.junit.Assert.assertEquals import org.junit.Test diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 01092dcf1d..20d697e8b6 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 7882749ac5..793881ef22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -45,15 +45,15 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.MetadataEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 7d20c24da6..8f028fb72e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -57,8 +57,8 @@ import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.quartz.nip01Core.toHexKey -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip03Timestamp.ots.OpenTimestamps import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull @@ -110,13 +110,13 @@ class ServiceManager( else -> HttpClientManager.setDefaultProxy(null) } - OtsEvent.otsInstance = + OtsResolver.ots = OpenTimestamps( OkHttpBlockstreamExplorer(myAccount::shouldUseTorForMoneyOperations), OkHttpCalendarBuilder(myAccount::shouldUseTorForMoneyOperations), ) } else { - OtsEvent.otsInstance = + OtsResolver.ots = OpenTimestamps( OkHttpBlockstreamExplorer { false }, OkHttpCalendarBuilder { false }, 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 da78ada8a0..3bde8f1b20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -53,58 +53,89 @@ import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.blossom.BlossomServersEvent +import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.StoryOption -import com.vitorpamplona.quartz.experimental.nip95.FileStorageEvent -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.image +import com.vitorpamplona.quartz.experimental.interactiveStories.summary +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nip95.header.blurhash +import com.vitorpamplona.quartz.experimental.nip95.header.dimension +import com.vitorpamplona.quartz.experimental.nip95.header.fileSize +import com.vitorpamplona.quartz.experimental.nip95.header.hash +import com.vitorpamplona.quartz.experimental.nip95.header.mimeType import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.MetadataEvent +import com.vitorpamplona.quartz.experimental.profileGallery.blurhash +import com.vitorpamplona.quartz.experimental.profileGallery.dimension +import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent +import com.vitorpamplona.quartz.experimental.profileGallery.hash +import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +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.EventHintBundle import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +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.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote +import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.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 import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers -import com.vitorpamplona.quartz.nip02FollowList.Contact +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds +import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.ReadWrite +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.reply import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip10Notes.PTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip17Dm.NIP17Group +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +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.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis -import com.vitorpamplona.quartz.nip34Git.GitReplyEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent @@ -117,29 +148,38 @@ import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +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.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureMeta +import com.vitorpamplona.quartz.nip68Picture.pictureIMeta import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoMeta import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -import com.vitorpamplona.quartz.nip96FileStorage.FileServersEvent +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dimension +import com.vitorpamplona.quartz.nip94FileMetadata.fileSize +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.nip99Classifieds.Price import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -163,8 +203,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.czeal.rfc3986.URIReference import java.math.BigDecimal +import java.util.Base64 import java.util.Locale -import java.util.UUID import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resume @@ -957,7 +997,7 @@ class Account( onReady: (LiveFollowList) -> Unit, ) { listEvent.privateTags(signer) { privateTagList -> - val users = (listEvent.taggedUsers() + listEvent.filterUsers(privateTagList)).toSet() + val users = (listEvent.taggedUserIds() + listEvent.filterUsers(privateTagList)).toSet() onReady( LiveFollowList( authors = users, @@ -965,7 +1005,7 @@ class Account( hashtags = (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(), geotags = (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(), addresses = - (listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList)) + (listEvent.taggedATags() + listEvent.filterATags(privateTagList)) .map { it.toTag() } .toSet(), ), @@ -1088,7 +1128,9 @@ class Account( fun getEmojiPackSelectionFlow(): StateFlow = getEmojiPackSelectionNote().flow().metadata.stateFlow - fun getEmojiPackSelectionNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(EmojiPackSelectionEvent.createAddressATag(userProfile().pubkeyHex)) + fun getEmojiPackSelectionAddress() = EmojiPackSelectionEvent.createAddress(userProfile().pubkeyHex) + + fun getEmojiPackSelectionNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getEmojiPackSelectionAddress()) fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List>? = selection?.taggedAddresses()?.map { @@ -1107,9 +1149,7 @@ class Account( .stateIn( scope, SharingStarted.Eagerly, - runBlocking(Dispatchers.Default) { - convertEmojiSelectionPack(getEmojiPackSelection()) - }, + convertEmojiSelectionPack(getEmojiPackSelection()), ) } @@ -1147,9 +1187,7 @@ class Account( .stateIn( scope, SharingStarted.Eagerly, - runBlocking(Dispatchers.Default) { - mergePack(convertEmojiSelectionPack(getEmojiPackSelection())?.map { it.value }?.toTypedArray() ?: emptyArray()) - }, + mergePack(convertEmojiSelectionPack(getEmojiPackSelection())?.map { it.value }?.toTypedArray() ?: emptyArray()), ) } @@ -1258,7 +1296,7 @@ class Account( } } - fun sendKind3RelayList(relays: Map) { + fun sendKind3RelayList(relays: Map) { if (!isWriteable()) return val contactList = userProfile().latestContactList @@ -1309,27 +1347,48 @@ class Account( ) { if (!isWriteable()) return - MetadataEvent.updateFromPast( - latest = userProfile().latestMetadata, - name = name, - picture = picture, - banner = banner, - website = website, - pronouns = pronouns, - about = about, - nip05 = nip05, - lnAddress = lnAddress, - lnURL = lnURL, - twitter = twitter, - mastodon = mastodon, - github = github, - signer = signer, - ) { + val latest = userProfile().latestMetadata + + val template = + if (latest != null) { + MetadataEvent.updateFromPast( + latest = latest, + name = name, + displayName = name, + picture = picture, + banner = banner, + website = website, + pronouns = pronouns, + about = about, + nip05 = nip05, + lnAddress = lnAddress, + lnURL = lnURL, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } else { + MetadataEvent.createNew( + name = name, + displayName = name, + picture = picture, + banner = banner, + website = website, + pronouns = pronouns, + about = about, + nip05 = nip05, + lnAddress = lnAddress, + lnURL = lnURL, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } + + signer.sign(template) { Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } - - return } fun reactionTo( @@ -1362,9 +1421,9 @@ class Account( val users = noteEvent.groupMembers().toList() if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrl.decode(reaction) + val emojiUrl = EmojiUrlTag.decode(reaction) if (emojiUrl != null) { - note.event?.let { + note.toEventHint()?.let { NIP17Factory().createReactionWithinGroup( emojiUrl = emojiUrl, originalNote = it, @@ -1379,7 +1438,7 @@ class Account( } } - note.event?.let { + note.toEventHint()?.let { NIP17Factory().createReactionWithinGroup( content = reaction, originalNote = it, @@ -1392,10 +1451,12 @@ class Account( return } else { if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrl.decode(reaction) + val emojiUrl = EmojiUrlTag.decode(reaction) if (emojiUrl != null) { note.event?.let { - ReactionEvent.create(emojiUrl, it, signer) { + signer.sign( + ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl())), + ) { Amethyst.instance.client.send(it) LocalCache.consume(it) } @@ -1405,8 +1466,10 @@ class Account( } } - note.event?.let { - ReactionEvent.create(reaction, it, signer) { + note.toEventHint()?.let { + signer.sign( + ReactionEvent.build(reaction, it), + ) { Amethyst.instance.client.send(it) LocalCache.consume(it) } @@ -1564,13 +1627,6 @@ class Account( return } - note.event?.let { - ReactionEvent.createWarning(it, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - note.event?.let { ReportEvent.create(it, type, signer, content) { Amethyst.instance.client.send(it) @@ -1607,7 +1663,9 @@ class Account( if (myNoteVersions.isNotEmpty()) { // chunks in 200 elements to avoid going over the 65KB limit for events. myNoteVersions.chunked(200).forEach { chunkedList -> - DeletionEvent.create(chunkedList, signer) { deletionEvent -> + signer.sign( + DeletionEvent.build(chunkedList), + ) { deletionEvent -> Amethyst.instance.client.send(deletionEvent) LocalCache.justConsume(deletionEvent, null) } @@ -1622,8 +1680,10 @@ class Account( ): HTTPAuthorizationEvent? { if (!isWriteable()) return null + val template = HTTPAuthorizationEvent.build(url, method, body) + return tryAndWait { continuation -> - HTTPAuthorizationEvent.create(url, method, body, signer) { + signer.sign(template) { continuation.resume(it) } } @@ -1658,24 +1718,26 @@ class Account( suspend fun boost(note: Note) { if (!isWriteable()) return + val noteEvent = note.event ?: return if (note.hasBoostedInTheLast5Minutes(userProfile())) { // has already bosted in the past 5mins return } - note.event?.let { - if (it.kind == 1) { - RepostEvent.create(it, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } + val noteHint = note.relayHintUrl() + val authorHint = note.author?.bestRelayHint() + + val template = + if (noteEvent.kind == 1) { + RepostEvent.build(noteEvent, noteHint, authorHint) } else { - GenericRepostEvent.create(it, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } + GenericRepostEvent.build(noteEvent, noteHint, authorHint) } + + signer.sign(template) { + Amethyst.instance.client.send(it) + LocalCache.justConsume(it, null) } } @@ -1708,10 +1770,19 @@ class Account( Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") settings.pendingAttestations.value.forEach { pair -> - val newAttestation = OtsEvent.upgrade(pair.value, pair.key) + val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(pair.value), pair.key) - if (pair.value != newAttestation) { - OtsEvent.create(pair.key, newAttestation, signer) { + if (otsState != null) { + val hint = LocalCache.getNoteIfExists(pair.key)?.toEventHint() + + val template = + if (hint != null) { + OtsEvent.build(hint, otsState) + } else { + OtsEvent.build(pair.key, otsState) + } + + signer.sign(template) { LocalCache.justConsume(it, null) Amethyst.instance.client.send(it) @@ -1734,7 +1805,7 @@ class Account( val id = note.event?.id ?: note.idHex - settings.addPendingAttestation(id, OtsEvent.stamp(id)) + settings.addPendingAttestation(id, Base64.getEncoder().encodeToString(OtsEvent.stamp(id))) } fun follow(user: User) { @@ -1749,14 +1820,14 @@ class Account( } } else { ContactListEvent.createFromScratch( - followUsers = listOf(Contact(user.pubkeyHex, null)), + followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), followTags = emptyList(), followGeohashes = emptyList(), followCommunities = emptyList(), followEvents = DefaultChannels.toList(), relayUse = Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) + it.url to ReadWrite(it.read, it.write) }, signer = signer, ) { @@ -1785,7 +1856,7 @@ class Account( followEvents = DefaultChannels.toList().plus(channel.idHex), relayUse = Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) + it.url to ReadWrite(it.read, it.write) }, signer = signer, ) { @@ -1801,20 +1872,20 @@ class Account( val contactList = userProfile().latestContactList if (contactList != null) { - ContactListEvent.followAddressableEvent(contactList, community.address, signer) { + ContactListEvent.followAddressableEvent(contactList, community.toATag(), signer) { Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { val relays = Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) + it.url to ReadWrite(it.read, it.write) } ContactListEvent.createFromScratch( followUsers = emptyList(), followTags = emptyList(), followGeohashes = emptyList(), - followCommunities = listOf(community.address), + followCommunities = listOf(community.toATag()), followEvents = DefaultChannels.toList(), relayUse = relays, signer = signer, @@ -1848,7 +1919,7 @@ class Account( followEvents = DefaultChannels.toList(), relayUse = Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) + it.url to ReadWrite(it.read, it.write) }, signer = signer, ) { @@ -1879,7 +1950,7 @@ class Account( followEvents = DefaultChannels.toList(), relayUse = Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) + it.url to ReadWrite(it.read, it.write) }, signer = signer, onReady = this::onNewEventCreated, @@ -1960,7 +2031,7 @@ class Account( if (contactList != null && contactList.tags.isNotEmpty()) { ContactListEvent.unfollowAddressableEvent( contactList, - community.address, + community.toATag(), signer, onReady = this::onNewEventCreated, ) @@ -1971,27 +2042,25 @@ class Account( byteArray: ByteArray, headerInfo: FileHeader, alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, onReady: (Pair) -> Unit, ) { if (!isWriteable()) return - FileStorageEvent.create( - mimeType = headerInfo.mimeType ?: "", - data = byteArray, - signer = signer, - ) { data -> - FileStorageHeaderEvent.create( - data, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash?.blurhash, - alt = alt, - sensitiveContent = sensitiveContent, - signer = signer, - ) { signedEvent -> + signer.sign(FileStorageEvent.build(byteArray, headerInfo.mimeType)) { data -> + val template = + FileStorageHeaderEvent.build(EventHintBundle(data, userProfile().bestRelayHint()), alt) { + hash(headerInfo.hash) + fileSize(headerInfo.size) + + headerInfo.mimeType?.let { mimeType(it) } + headerInfo.dim?.let { dimension(it) } + headerInfo.blurHash?.let { blurhash(it.blurhash) } + + contentWarningReason?.let { contentWarning(contentWarningReason) } + } + + signer.sign(template) { signedEvent -> onReady( Pair(data, signedEvent), ) @@ -2050,33 +2119,34 @@ class Account( magnetUri: String?, headerInfo: FileHeader, alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String? = null, originalHash: String? = null, onReady: (FileHeaderEvent) -> Unit, ) { if (!isWriteable()) return - FileHeaderEvent.create( - url = imageUrl, - magnetUri = magnetUri, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash?.blurhash, - alt = alt, - originalHash = originalHash, - sensitiveContent = sensitiveContent, - signer = signer, - ) { event -> - onReady(event) - } + 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( urlHeaderInfo: Map, caption: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, relayList: List, onReady: (Note) -> Unit, ) { @@ -2089,19 +2159,28 @@ class Account( it.value.dim, caption, it.value.hash, - it.value.size.toLong(), + it.value.size, + null, emptyList(), emptyList(), ) } - PictureEvent.create( - images = iMetas, - msg = caption, - markAsSensitive = sensitiveContent, - signer = signer, - ) { event -> - sendHeader(event, relayList = relayList, onReady) + signer.sign( + PictureEvent.build(iMetas, caption ?: "") { + caption?.let { + hashtags(findHashtags(it)) + references(findURLs(it)) + quotes(findNostrUris(it)) + } + // add zap splits + // add zap raiser + // add geohashes + // add title + contentWarningReason?.let { contentWarning(contentWarningReason) } + }, + ) { + sendHeader(it, relayList = relayList, onReady) } } @@ -2110,7 +2189,7 @@ class Account( magnetUri: String?, headerInfo: FileHeader, alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, originalHash: String? = null, relayList: List, onReady: (Note) -> Unit, @@ -2120,276 +2199,164 @@ class Account( val isImage = headerInfo.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url) val isVideo = headerInfo.mimeType?.startsWith("video/") == true || RichTextParser.isVideoUrl(url) - if (isImage) { - PictureEvent.create( - url = url, - msg = alt, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toLong(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash?.blurhash, - markAsSensitive = sensitiveContent, - alt = alt, - signer = signer, - ) { event -> - sendHeader(event, relayList = relayList, onReady) - } - } else if (isVideo && headerInfo.dim != null) { - if (headerInfo.dim.height > headerInfo.dim.width) { - VideoVerticalEvent.create( - url = url, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size, - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash?.blurhash, - alt = alt, - sensitiveContent = sensitiveContent, - signer = signer, - ) { event -> - sendHeader(event, relayList = relayList, onReady) + val template = + if (isImage) { + PictureEvent.build(alt ?: "") { + alt?.let { + hashtags(findHashtags(it)) + references(findURLs(it)) + quotes(findNostrUris(it)) + } + pictureIMeta( + url, + headerInfo.mimeType, + headerInfo.blurHash?.blurhash, + headerInfo.dim, + headerInfo.hash, + headerInfo.size, + alt, + ) + // add zap splits + // add zap raiser + // add geohashes + // add title + contentWarningReason?.let { contentWarning(contentWarningReason) } + } + } else if (isVideo && headerInfo.dim != null) { + val videoMeta = + VideoMeta( + url = url, + hash = headerInfo.hash, + size = headerInfo.size, + mimeType = headerInfo.mimeType, + dimension = headerInfo.dim, + blurhash = headerInfo.blurHash?.blurhash, + alt = alt, + ) + + if (headerInfo.dim.height > headerInfo.dim.width) { + VideoVerticalEvent.build(videoMeta, alt ?: "") { + contentWarningReason?.let { contentWarning(contentWarningReason) } + } + } else { + VideoHorizontalEvent.build(videoMeta, alt ?: "") { + contentWarningReason?.let { contentWarning(contentWarningReason) } + } } } else { - VideoHorizontalEvent.create( - url = url, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size, - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash?.blurhash, - alt = alt, - sensitiveContent = sensitiveContent, - signer = signer, - ) { event -> - sendHeader(event, relayList = relayList, onReady) + FileHeaderEvent.build(url, 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) } + } + } + + signer.sign(template) { + sendHeader(it, relayList = relayList, onReady) + } + } + + fun signAndSend( + draftTag: String?, + template: EventTemplate, + ) { + if (draftTag != null) { + if (template.content.isEmpty()) { + deleteDraft(draftTag) + } else { + signer.assembleRumor(template) { rumor -> + DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> + sendDraftEvent(draftEvent) + } } } } else { - FileHeaderEvent.create( - url = url, - magnetUri = magnetUri, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash?.blurhash, - alt = alt, - originalHash = originalHash, - sensitiveContent = sensitiveContent, - signer = signer, - ) { event -> - sendHeader(event, relayList = relayList, onReady) + signer.sign(template) { + LocalCache.justConsume(it, null) + Amethyst.instance.client.send(it) } } } - fun sendClassifieds( - title: String, - price: Price, - condition: ClassifiedsEvent.CONDITION, - location: String, - category: String, - message: String, - replyTo: List?, - mentions: List?, - directMentions: Set, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - relayList: List, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, + fun signAndSend( draftTag: String?, + template: EventTemplate, + relayList: List, + broadcastNotes: List, + ) = signAndSend(draftTag, template, relayList, mapEntitiesToNotes(broadcastNotes).toSet()) + + fun signAndSend( + draftTag: String?, + template: EventTemplate, + relayList: List, + broadcastNotes: Set, ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - ClassifiedsEvent.create( - dTag = UUID.randomUUID().toString(), - title = title, - price = price, - condition = condition, - summary = message, - image = null, - location = location, - category = category, - message = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - directMentions = directMentions, - geohash = geohash, - imetas = imetas, - emojis = emojis, - signer = signer, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } + if (draftTag != null) { + signer.assembleRumor(template) { rumor -> + DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> + sendDraftEvent(draftEvent) } - } else { - Amethyst.instance.client.send(it, relayList = relayList) + } + } else { + signer.sign(template) { LocalCache.justConsume(it, null) + Amethyst.instance.client.send(it, relayList = relayList) - replyTo?.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } + broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } } } } - fun sendGitReply( - message: String, - replyTo: List?, - mentions: List?, - repository: ATag?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - replyingTo: String?, - root: String?, - directMentions: Set, - forkedFrom: Event?, - relayList: List, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, + fun signAndSendWithList( draftTag: String?, + template: EventTemplate, + relayList: List, + broadcastNotes: Set, ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = listOfNotNull(repository) + (replyTo?.mapNotNull { it.address() } ?: emptyList()) - - GitReplyEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - replyingTo = replyingTo, - root = root, - directMentions = directMentions, - geohash = geohash, - imetas = imetas, - emojis = emojis, - forkedFrom = forkedFrom, - signer = signer, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } + if (draftTag != null) { + signer.assembleRumor(template) { rumor -> + DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> + sendDraftEvent(draftEvent) } - } else { - Amethyst.instance.client.send(it, relayList = relayList) + } + } else { + signer.sign(template) { + val connect = + relayList.map { + val normalizedUrl = RelayUrlFormatter.normalize(it) + RelaySetupInfoToConnect( + normalizedUrl, + shouldUseTorForClean(normalizedUrl), + true, + true, + setOf(FeedType.GLOBAL), + ) + } + LocalCache.justConsume(it, null) - - // broadcast replied notes - replyingTo?.let { - LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } - replyTo?.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } + Amethyst.instance.client.sendPrivately(it, relayList = connect) + broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.sendPrivately(it, relayList = connect) } } } } } fun sendTorrentComment( - message: String, - replyTo: List?, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - replyingTo: String?, - root: String, - directMentions: Set, - forkedFrom: Event?, - relayList: List, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, draftTag: String?, + template: EventTemplate, + broadcastNotes: Set, + relayList: List, ) { if (!isWriteable()) return - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } ?: emptyList() - - TorrentCommentEvent.create( - message = message, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - replyingTo = replyingTo, - torrent = root, - directMentions = directMentions, - geohash = geohash, - imetas = imetas, - emojis = emojis, - forkedFrom = forkedFrom, - signer = signer, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - // broadcast replied notes - replyingTo?.let { - LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } - replyTo?.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } - } - } + signAndSend(draftTag, template, relayList, broadcastNotes) } fun deleteDraft(draftTag: String) { @@ -2417,221 +2384,6 @@ class Account( } } - suspend fun sendReplyComment( - message: String, - replyingTo: Note, - directMentionsUsers: Set = emptySet(), - directMentionsNotes: Set = emptySet(), - imetas: List? = null, - emojis: List? = null, - geohash: String? = null, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - relayList: List, - draftTag: String? = null, - ) { - if (!isWriteable()) return - - val usersMentioned = - directMentionsUsers - .mapTo(HashSet(directMentionsUsers.size)) { - PTag(it.pubkeyHex, it.latestMetadataRelay) - } - - val addressesMentioned = - directMentionsNotes - .mapNotNullTo(HashSet(directMentionsNotes.size)) { note -> - if (note is AddressableNote) { - note.address - } else { - null - } - } - - val eventsMentioned = - directMentionsNotes - .mapNotNullTo(HashSet(directMentionsNotes.size)) { note -> - if (note !is AddressableNote) { - ETag(note.idHex, note.relayHintUrl(), note.author?.pubkeyHex) - } else { - null - } - } - - if (replyingTo.event is CommentEvent) { - CommentEvent.replyComment( - msg = message, - replyingTo = EventHintBundle(replyingTo.event as CommentEvent, replyingTo.relayHintUrl()), - usersMentioned = usersMentioned, - addressesMentioned = addressesMentioned, - eventsMentioned = eventsMentioned, - imetas = imetas, - emojis = emojis, - geohash = geohash, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - isDraft = draftTag != null, - signer = signer, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - replyingTo.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } - } - } else { - CommentEvent.firstReplyToEvent( - msg = message, - replyingTo = EventHintBundle(replyingTo.event as Event, replyingTo.relayHintUrl()), - usersMentioned = usersMentioned, - addressesMentioned = addressesMentioned, - eventsMentioned = eventsMentioned, - imetas = imetas, - emojis = emojis, - geohash = geohash, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - isDraft = draftTag != null, - signer = signer, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - replyingTo.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } - } - } - } - - suspend fun sendGeoComment( - message: String, - geohash: String, - replyingTo: Note? = null, - directMentionsUsers: Set = emptySet(), - directMentionsNotes: Set = emptySet(), - imetas: List? = null, - emojis: List? = null, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - relayList: List, - draftTag: String? = null, - ) { - if (!isWriteable()) return - - val usersMentioned = - directMentionsUsers - .mapTo(HashSet(directMentionsUsers.size)) { - PTag(it.pubkeyHex, it.latestMetadataRelay) - } - - val addressesMentioned = - directMentionsNotes - .mapNotNullTo(HashSet(directMentionsNotes.size)) { note -> - if (note is AddressableNote) { - note.address - } else { - null - } - } - - val eventsMentioned = - directMentionsNotes - .mapNotNullTo(HashSet(directMentionsNotes.size)) { note -> - if (note !is AddressableNote) { - ETag(note.idHex, note.relayHintUrl(), note.author?.pubkeyHex) - } else { - null - } - } - - if (replyingTo != null) { - CommentEvent.replyComment( - msg = message, - replyingTo = EventHintBundle(replyingTo.event as CommentEvent, replyingTo.relayHintUrl()), - usersMentioned = usersMentioned, - addressesMentioned = addressesMentioned, - eventsMentioned = eventsMentioned, - imetas = imetas, - emojis = emojis, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - isDraft = draftTag != null, - signer = signer, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - replyingTo.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } - } - } else { - CommentEvent.createGeoComment( - msg = message, - geohash = geohash, - usersMentioned = usersMentioned, - addressesMentioned = addressesMentioned, - eventsMentioned = eventsMentioned, - imetas = imetas, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - isDraft = draftTag != null, - signer = signer, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - } - } - } - } - suspend fun createInteractiveStoryReadingState( root: InteractiveStoryBaseEvent, rootRelay: String?, @@ -2642,13 +2394,15 @@ class Account( val relayList = getPrivateOutBoxRelayList() - InteractiveStoryReadingStateEvent.create( - root = root, - rootRelay = rootRelay, - currentScene = readingScene, - currentSceneRelay = readingSceneRelay, - signer = signer, - ) { + val template = + InteractiveStoryReadingStateEvent.build( + root = root, + rootRelay = rootRelay, + currentScene = readingScene, + currentSceneRelay = readingSceneRelay, + ) + + signer.sign(template) { if (relayList.isNotEmpty()) { Amethyst.instance.client.sendPrivately(it, relayList = relayList) } else { @@ -2667,12 +2421,14 @@ class Account( val relayList = getPrivateOutBoxRelayList() - InteractiveStoryReadingStateEvent.update( - base = readingState, - currentScene = readingScene, - currentSceneRelay = readingSceneRelay, - signer = signer, - ) { + val template = + InteractiveStoryReadingStateEvent.update( + base = readingState, + currentScene = readingScene, + currentSceneRelay = readingSceneRelay, + ) + + signer.sign(template) { if (relayList.isNotEmpty()) { Amethyst.instance.client.sendPrivately(it, relayList = relayList) } else { @@ -2682,15 +2438,30 @@ class Account( } } + fun mapEntitiesToNotes(entities: List): List = + entities.mapNotNull { + when (it) { + is NPub -> null + is NProfile -> null + is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> LocalCache.getOrCreateNote(it.hex) + is NEvent -> LocalCache.getOrCreateNote(it.hex) + is NEmbed -> LocalCache.getOrCreateNote(it.event.id) + is NAddress -> LocalCache.checkGetOrCreateAddressableNote(it.aTag()) + is NSec -> null + is NRelay -> null + else -> null + } + } + suspend fun sendInteractiveStoryPrologue( baseId: String, title: String, content: String, - options: List, + options: List, summary: String? = null, image: String? = null, zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean = false, + contentWarningReason: String? = null, zapRaiserAmount: Long? = null, imetas: List? = null, draftTag: String? = null, @@ -2698,42 +2469,36 @@ class Account( ) { if (!isWriteable()) return - InteractiveStoryPrologueEvent.create( - baseId = baseId, - title = title, - content = content, - options = options, - summary = summary, - image = image, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - imetas = imetas, - signer = signer, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (content.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) + val quotes = findNostrUris(content) + + val template = + InteractiveStoryPrologueEvent.build( + baseId = baseId, + title = title, + content = content, + options = options, + ) { + summary?.let { summary(it) } + image?.let { image(it) } + hashtags(findHashtags(content)) + references(findURLs(content)) + quotes(quotes) + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + imetas?.let { imetas(it) } + contentWarningReason?.let { contentWarning(contentWarningReason) } } - } + + signAndSend(draftTag, template, relayList, quotes) } suspend fun sendInteractiveStoryScene( baseId: String, title: String, content: String, - options: List, + options: List, zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean = false, + contentWarningReason: String? = null, zapRaiserAmount: Long? = null, imetas: List? = null, draftTag: String? = null, @@ -2741,102 +2506,46 @@ class Account( ) { if (!isWriteable()) return - InteractiveStorySceneEvent.create( - baseId = baseId, - title = title, - content = content, - options = options, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - imetas = imetas, - signer = signer, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (content.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) + val quotes = findNostrUris(content) + + val template = + InteractiveStorySceneEvent.build( + baseId = baseId, + title = title, + content = content, + options = options, + ) { + hashtags(findHashtags(content)) + references(findURLs(content)) + quotes(quotes) + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + imetas?.let { imetas(it) } + contentWarningReason?.let { contentWarning(contentWarningReason) } } - } + + signAndSend(draftTag, template, relayList, mapEntitiesToNotes(quotes).toSet()) } - suspend fun sendPost( - message: String, - replyTo: List?, - mentions: List?, - tags: List? = null, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - replyingTo: String?, - root: String?, - directMentions: Set, - forkedFrom: Event?, - relayList: List, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, + suspend fun sendAddBounty( + value: BigDecimal, + bounty: Note, draftTag: String?, + relayList: List, ) { if (!isWriteable()) return - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } + val event = bounty.event as? TextNoteEvent ?: return + val eventAuthor = bounty.author ?: return - TextNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - extraTags = tags, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - replyingTo = replyingTo, - root = root, - directMentions = directMentions, - geohash = geohash, - imetas = imetas, - emojis = emojis, - forkedFrom = forkedFrom, - signer = signer, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) + val template = + BountyAddValueEvent.build( + value, + EventHintBundle(event, bounty.relayHintUrl()), + eventAuthor.toPTag(), + ) - // broadcast replied notes - replyingTo?.let { - LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } - replyTo?.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } - } - } + signAndSend(draftTag, template, relayList, setOf(bounty)) } fun sendEdit( @@ -2862,326 +2571,91 @@ class Account( } } - fun sendPoll( - message: String, - replyTo: List?, - mentions: List?, - pollOptions: Map, - valueMaximum: Int?, - valueMinimum: Int?, - consensusThreshold: Int?, - closedAt: Int?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - relayList: List, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - draftTag: String?, - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - PollNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - signer = signer, - pollOptions = pollOptions, - valueMaximum = valueMaximum, - valueMinimum = valueMinimum, - consensusThreshold = consensusThreshold, - closedAt = closedAt, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - imetas = imetas, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - // Rebroadcast replies and tags to the current relay set - replyTo?.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Amethyst.instance.client.send(it, relayList = relayList) - } - } - } - } - } - - fun sendChannelMessage( - message: String, - toChannel: String, - replyTo: List?, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - directMentions: Set, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - draftTag: String?, - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - - ChannelMessageEvent.create( - message = message, - channel = toChannel, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - directMentions = directMentions, - geohash = geohash, - imetas = imetas, - emojis = emojis, - signer = signer, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun sendLiveMessage( - message: String, - toChannel: ATag, - replyTo: List?, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - draftTag: String?, - ) { - if (!isWriteable()) return - - // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - - LiveActivitiesChatMessageEvent.create( - message = message, - activity = toChannel, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - imetas = imetas, - emojis = emojis, - signer = signer, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - fun sendPrivateMessage( message: String, toUser: User, replyingTo: Note? = null, - mentions: List?, zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, + contentWarningReason: String? = null, zapRaiserAmount: Long? = null, geohash: String? = null, imetas: List? = null, + emojis: List? = null, draftTag: String?, ) { sendPrivateMessage( message, - toUser.pubkeyHex, + toUser.toPTag(), replyingTo, - mentions, zapReceiver, - wantsToMarkAsSensitive, + contentWarningReason, zapRaiserAmount, geohash, imetas, + emojis, draftTag, ) } fun sendPrivateMessage( message: String, - toUser: HexKey, + toUser: PTag, replyingTo: Note? = null, - mentions: List?, zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, + contentWarningReason: String? = null, zapRaiserAmount: Long? = null, geohash: String? = null, imetas: List? = null, + emojis: List? = null, draftTag: String?, ) { if (!isWriteable()) return - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = mentions?.map { it.pubkeyHex } + signer.nip04Encrypt( + PrivateDmEvent.prepareMessageToEncrypt(message, imetas), + toUser.pubKey, + ) { encryptedContent -> + val template = + PrivateDmEvent.build(toUser, encryptedContent) { + replyingTo?.let { reply(it.toEId()) } - PrivateDmEvent.create( - recipientPubKey = toUser, - publishedRecipientPubKey = toUser, - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - imetas = imetas, - signer = signer, - advertiseNip18 = false, - isDraft = draftTag != null, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } + geohash?.let { geohash(it) } + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + emojis?.let { emojis(it) } + contentWarningReason?.let { contentWarning(contentWarningReason) } } - } else { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } + + signAndSend(draftTag, template) } } - fun sendNIP17EncryptedFile( - url: String, - toUsers: List, - replyingTo: Note? = null, - contentType: String?, - algo: String, - key: ByteArray, - nonce: ByteArray? = null, - originalHash: String? = null, - hash: String? = null, - size: Int? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - sensitiveContent: Boolean? = null, - alt: String?, - ) { + fun sendNIP17EncryptedFile(template: EventTemplate) { if (!isWriteable()) return - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - - NIP17Factory().createEncryptedFileNIP17( - url = url, - to = toUsers, - repliesToHex = repliesToHex, - contentType = contentType, - algo = algo, - key = key, - nonce = nonce, - originalHash = originalHash, - hash = hash, - size = size, - dimensions = dimensions, - blurhash = blurhash, - sensitiveContent = sensitiveContent, - alt = alt, - draftTag = null, - signer = signer, - ) { + NIP17Factory().createEncryptedFileNIP17(template, signer) { broadcastPrivately(it) } } fun sendNIP17PrivateMessage( - message: String, - toUsers: List, - subject: String? = null, - replyingTo: Note? = null, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, + template: EventTemplate, draftTag: String? = null, ) { if (!isWriteable()) return - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = mentions?.map { it.pubkeyHex } - - NIP17Factory().createMsgNIP17( - msg = message, - to = toUsers, - subject = subject, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - imetas = imetas, - emojis = emojis, - draftTag = draftTag, - signer = signer, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) - } else { - DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent -> + if (draftTag != null) { + if (template.content.isEmpty()) { + deleteDraft(draftTag) + } else { + signer.assembleRumor(template) { + DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> sendDraftEvent(draftEvent) } } - } else { + } + } else { + NIP17Factory().createMessageNIP17(template, signer) { broadcastPrivately(it) } } @@ -3263,19 +2737,21 @@ class Account( } } - fun sendCreateNewChannel( - name: String, - about: String, - picture: String, - ) { + fun sendChangeChannel(template: EventTemplate) { if (!isWriteable()) return - ChannelCreateEvent.create( - name = name, - about = about, - picture = picture, - signer = signer, - ) { + signer.sign(template) { + Amethyst.instance.client.send(it) + LocalCache.justConsume(it, null) + + it.channelId()?.let { LocalCache.getChannelIfExists(it)?.let { follow(it) } } + } + } + + fun sendCreateNewChannel(template: EventTemplate) { + if (!isWriteable()) return + + signer.sign(template) { Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) @@ -3313,7 +2789,9 @@ class Account( Amethyst.instance.client.send(event) LocalCache.justConsume(event, null) - DeletionEvent.createForVersionOnly(listOf(event), signer) { event2 -> + signer.sign( + DeletionEvent.buildForVersionOnly(listOf(event)), + ) { event2 -> Amethyst.instance.client.send(event2) LocalCache.justConsume(event2, null) } @@ -3322,19 +2800,16 @@ class Account( fun removeEmojiPack( usersEmojiList: Note, - emojiList: Note, + emojiPack: Note, ) { if (!isWriteable()) return val noteEvent = usersEmojiList.event if (noteEvent !is EmojiPackSelectionEvent) return - val emojiListEvent = emojiList.event - if (emojiListEvent !is EmojiPackEvent) return + val emojiPackEvent = emojiPack.event + if (emojiPackEvent !is EmojiPackEvent) return - EmojiPackSelectionEvent.create( - noteEvent.taggedAddresses().filter { it != emojiListEvent.address() }, - signer, - ) { + signer.sign(EmojiPackSelectionEvent.remove(noteEvent, emojiPackEvent)) { Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } @@ -3342,17 +2817,16 @@ class Account( fun addEmojiPack( usersEmojiList: Note, - emojiList: Note, + emojiPack: Note, ) { if (!isWriteable()) return - val emojiListEvent = emojiList.event - if (emojiListEvent !is EmojiPackEvent) return + val emojiPackEvent = emojiPack.event + if (emojiPackEvent !is EmojiPackEvent) return + + val eventHint = emojiPack.toEventHint() ?: return if (usersEmojiList.event == null) { - EmojiPackSelectionEvent.create( - listOf(emojiListEvent.address()), - signer, - ) { + signer.sign(EmojiPackSelectionEvent.build(listOf(eventHint))) { Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } @@ -3360,14 +2834,7 @@ class Account( val noteEvent = usersEmojiList.event if (noteEvent !is EmojiPackSelectionEvent) return - if (noteEvent.taggedAddresses().any { it == emojiListEvent.address() }) { - return - } - - EmojiPackSelectionEvent.create( - noteEvent.taggedAddresses().plus(emojiListEvent.address()), - signer, - ) { + signer.sign(EmojiPackSelectionEvent.add(noteEvent, eventHint)) { Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } @@ -3375,32 +2842,27 @@ class Account( } fun addToGallery( - idHex: String, + idHex: HexKey, url: String, relay: String?, blurhash: String?, - dim: Dimension?, + dim: DimensionTag?, hash: String?, mimeType: String?, ) { if (!isWriteable()) return - ProfileGalleryEntryEvent.create( - url = url, - eventid = idHex, - relayhint = relay, - blurhash = blurhash, - hash = hash, - dimensions = dim, - mimeType = mimeType, - /*magnetUri = magnetUri, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - alt = alt, - originalHash = originalHash, */ - signer = signer, - ) { event -> - Amethyst.instance.client.send(event) - LocalCache.consume(event, null) + + signer.sign( + ProfileGalleryEntryEvent.build(url) { + fromEvent(idHex, relay) + hash?.let { hash(hash) } + mimeType?.let { mimeType(it) } + dim?.let { dimension(it) } + blurhash?.let { blurhash(it) } + }, + ) { + Amethyst.instance.client.send(it) + LocalCache.consume(it, null) } } @@ -3418,7 +2880,7 @@ class Account( if (note is AddressableNote) { BookmarkListEvent.addReplaceable( userProfile().latestBookmarkList, - note.address, + note.toATag(), isPrivate, signer, ) { @@ -3449,7 +2911,7 @@ class Account( if (note is AddressableNote) { BookmarkListEvent.removeReplaceable( bookmarks, - note.address, + note.toATag(), isPrivate, signer, ) { @@ -3512,7 +2974,7 @@ class Account( } if (note is AddressableNote) { - userProfile().latestBookmarkList?.privateTaggedAddresses(signer) { + userProfile().latestBookmarkList?.privateAddress(signer) { onReady(it.contains(note.address)) } } else { @@ -3526,40 +2988,19 @@ class Account( if (!isWriteable()) return false if (note is AddressableNote) { - return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true + return userProfile().latestBookmarkList?.isTaggedAddressableNote(note.idHex) == true } else { - return userProfile().latestBookmarkList?.taggedEventIds()?.contains(note.idHex) == true + return userProfile().latestBookmarkList?.isTaggedEvent(note.idHex) == true } } - fun getAppSpecificDataNote(): AddressableNote { - val aTag = AppSpecificDataEvent.createTag(userProfile().pubkeyHex, APP_SPECIFIC_DATA_D_TAG) - return LocalCache.getOrCreateAddressableNote(aTag) - } + fun getAppSpecificDataNote() = LocalCache.getOrCreateAddressableNote(AppSpecificDataEvent.createAddress(userProfile().pubkeyHex, APP_SPECIFIC_DATA_D_TAG)) fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow - fun getBlockListNote(): AddressableNote { - val aTag = - ATag( - PeopleListEvent.KIND, - userProfile().pubkeyHex, - PeopleListEvent.BLOCK_LIST_D_TAG, - null, - ) - return LocalCache.getOrCreateAddressableNote(aTag) - } + fun getBlockListNote() = LocalCache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(userProfile().pubkeyHex)) - fun getMuteListNote(): AddressableNote { - val aTag = - ATag( - MuteListEvent.KIND, - userProfile().pubkeyHex, - "", - null, - ) - return LocalCache.getOrCreateAddressableNote(aTag) - } + fun getMuteListNote() = LocalCache.getOrCreateAddressableNote(MuteListEvent.createAddress(userProfile().pubkeyHex)) suspend fun getFollowSetNotes() = withContext(Dispatchers.Default) { @@ -3616,7 +3057,6 @@ class Account( PeopleListEvent.removeWord( earlierVersion = blockList, word = word, - isPrivate = true, signer = signer, ) { Amethyst.instance.client.send(it) @@ -3630,7 +3070,6 @@ class Account( MuteListEvent.removeWord( earlierVersion = muteList, word = word, - isPrivate = true, signer = signer, ) { Amethyst.instance.client.send(it) @@ -3671,7 +3110,6 @@ class Account( PeopleListEvent.removeUser( earlierVersion = blockList, pubKeyHex = pubkeyHex, - isPrivate = true, signer = signer, ) { Amethyst.instance.client.send(it) @@ -3685,7 +3123,6 @@ class Account( MuteListEvent.removeUser( earlierVersion = muteList, pubKeyHex = pubkeyHex, - isPrivate = true, signer = signer, ) { Amethyst.instance.client.send(it) @@ -3703,28 +3140,6 @@ class Account( return contactList?.taggedEventIds()?.toSet() ?: DefaultChannels } - fun sendChangeChannel( - name: String, - about: String, - picture: String, - channel: Channel, - ) { - if (!isWriteable()) return - - ChannelMetadataEvent.create( - name, - about, - picture, - originalChannelIdHex = channel.idHex, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - - follow(channel) - } - } - fun requestDVMContentDiscovery( dvmPublicKey: String, onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, @@ -3780,10 +3195,16 @@ class Account( fun cachedDecryptContent(event: Event?): String? { if (event == null) return null - return if (event is PrivateDmEvent && isWriteable()) { - event.cachedContentFor(signer) - } else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) { - event.cachedPrivateZap()?.content + return if (isWriteable()) { + if (event is PrivateDmEvent) { + event.cachedContentFor(signer) + } else if (event is LnZapRequestEvent && event.isPrivateZap()) { + event.cachedPrivateZap()?.content + } else if (event is DraftEvent) { + event.preCachedDraft(signer)?.content + } else { + event.content + } } else { event.content } @@ -3942,14 +3363,11 @@ class Account( fun saveKind3RelayList(value: List) { settings.updateLocalRelays(value.toSet()) sendKind3RelayList( - value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, + value.associate { it.url to ReadWrite(it.read, it.write) }, ) } - fun getDMRelayListNote(): AddressableNote = - LocalCache.getOrCreateAddressableNote( - ChatMessageRelayListEvent.createAddressATag(signer.pubKey), - ) + fun getDMRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(signer.pubKey)) fun getDMRelayListFlow(): StateFlow = getDMRelayListNote().flow().metadata.stateFlow @@ -3981,7 +3399,7 @@ class Account( fun getPrivateOutboxRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote( - PrivateOutboxRelayListEvent.createAddressATag(signer.pubKey), + PrivateOutboxRelayListEvent.createAddress(signer.pubKey), ) fun getPrivateOutboxRelayListFlow(): StateFlow = getPrivateOutboxRelayListNote().flow().metadata.stateFlow @@ -4015,7 +3433,7 @@ class Account( fun getSearchRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote( - SearchRelayListEvent.createAddressATag(signer.pubKey), + SearchRelayListEvent.createAddress(signer.pubKey), ) fun getSearchRelayListFlow(): StateFlow = getSearchRelayListNote().flow().metadata.stateFlow @@ -4049,7 +3467,7 @@ class Account( fun getNIP65RelayListNote(pubkey: HexKey = signer.pubKey): AddressableNote = LocalCache.getOrCreateAddressableNote( - AdvertisedRelayListEvent.createAddressATag(pubkey), + AdvertisedRelayListEvent.createAddress(pubkey), ) fun getNIP65RelayListFlow(pubkey: HexKey = signer.pubKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow @@ -4085,13 +3503,13 @@ class Account( fun getFileServersListFlow(): StateFlow = getFileServersNote().flow().metadata.stateFlow - fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddressATag(userProfile().pubkeyHex)) + fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddress(userProfile().pubkeyHex)) fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent fun getBlossomServersListFlow(): StateFlow = getBlossomServersNote().flow().metadata.stateFlow - fun getBlossomServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(BlossomServersEvent.createAddressATag(userProfile().pubkeyHex)) + fun getBlossomServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(BlossomServersEvent.createAddress(userProfile().pubkeyHex)) fun host(url: String): String = try { @@ -4117,23 +3535,16 @@ class Account( val serverList = getFileServersList() - if (serverList != null && serverList.tags.isNotEmpty()) { - FileServersEvent.updateRelayList( - earlierVersion = serverList, - relays = servers, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - FileServersEvent.createFromScratch( - relays = servers, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) + val template = + if (serverList != null && serverList.tags.isNotEmpty()) { + FileServersEvent.replaceServers(serverList, servers) + } else { + FileServersEvent.build(servers) } + + signer.sign(template) { + Amethyst.instance.client.send(it) + LocalCache.justConsume(it, null) } } @@ -4170,7 +3581,7 @@ class Account( val event = (addressableNote.event as? PeopleListEvent) event != null && event.pubKey == pubkey && - (event.hasAnyTaggedUser() || event.publicAndPrivateUserCache?.isNotEmpty() == true) + (event.hasAnyTaggedUser() || event.cachedPrivateTags()?.isNotEmpty() == true) } fun markAsRead( 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 c20c9c0ab4..7ea3514436 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -30,13 +30,13 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.ammolite.relays.Constants import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip01Core.toHexKey import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 5a2b43d434..7305d40300 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import kotlinx.coroutines.flow.MutableStateFlow 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 3ad4b30d16..a7dd1365f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -28,12 +28,16 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.ammolite.relays.BundledUpdate -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip19Bech32.toNAddr +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.toNEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.utils.Hex import kotlinx.coroutines.Dispatchers @@ -41,11 +45,23 @@ import kotlinx.coroutines.Dispatchers class PublicChatChannel( idHex: String, ) : Channel(idHex) { - var info = ChannelCreateEvent.ChannelData(null, null, null) + var event: ChannelCreateEvent? = null + var info = ChannelData(null, null, null, null) + + override fun relays() = info.relays ?: super.relays() fun updateChannelInfo( creator: User, - channelInfo: ChannelCreateEvent.ChannelData, + channelInfo: ChannelCreateEvent, + updatedAt: Long, + ) { + this.event = channelInfo + updateChannelInfo(creator, channelInfo.channelInfo(), updatedAt) + } + + fun updateChannelInfo( + creator: User, + channelInfo: ChannelData, updatedAt: Long, ) { this.info = channelInfo @@ -66,16 +82,20 @@ class PublicChatChannel( @Stable class LiveActivitiesChannel( - val address: ATag, -) : Channel(address.toTag()) { + val address: Address, +) : Channel(address.toValue()) { var info: LiveActivitiesEvent? = null - override fun idNote() = address.toNAddr() + override fun idNote() = toNAddr() override fun idDisplayNote() = idNote().toShortenHex() fun address() = address + override fun relays() = info?.allRelayUrls() ?: super.relays() + + fun relayHintUrl() = relays().firstOrNull() + fun updateChannelInfo( creator: User, channelInfo: LiveActivitiesEvent, @@ -95,8 +115,16 @@ class LiveActivitiesChannel( listOfNotNull(info?.title(), info?.summary()) .filter { it.contains(prefix, true) } .isNotEmpty() + + fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, relayHintUrl()) + + fun toATag() = ATag(address, relayHintUrl()) } +data class Counter( + var number: Int = 0, +) + @Stable abstract class Channel( val idHex: String, @@ -105,6 +133,7 @@ abstract class Channel( var updatedMetadataAt: Long = 0 val notes = LargeCache() var lastNoteCreatedAt: Long = 0 + private var relays = mapOf() open fun id() = Hex.decode(idHex) @@ -120,6 +149,14 @@ abstract class Channel( open fun profilePicture(): String? = creator?.info?.banner + open fun relays() = + relays.keys + .toSortedSet { o1, o2 -> + val o1Count = relays[o1]?.number ?: 0 + val o2Count = relays[o2]?.number ?: 0 + o2Count.compareTo(o1Count) // descending + }.map { it.url } + open fun updateChannelInfo( creator: User, updatedAt: Long, @@ -130,12 +167,35 @@ abstract class Channel( live.invalidateData() } - fun addNote(note: Note) { + @Synchronized + fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) { + if (briefInfo !in relays) { + relays = relays + Pair(briefInfo, Counter(1)) + } + } + + fun addRelay(relay: Relay) { + val counter = relays[relay.brief] + if (counter != null) { + counter.number++ + } else { + addRelaySync(relay.brief) + } + } + + fun addNote( + note: Note, + relay: Relay? = null, + ) { notes.put(note.idHex, note) if ((note.createdAt() ?: 0) > lastNoteCreatedAt) { lastNoteCreatedAt = note.createdAt() ?: 0 } + + if (relay != null) { + addRelay(relay) + } } fun removeNote(note: Note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt index dc4e636489..77b71ebe51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent +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 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 3117bf95de..96c4d015ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -31,49 +31,52 @@ import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.blossom.BlossomServersEvent -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +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.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent -import com.vitorpamplona.quartz.experimental.nip95.FileStorageEvent -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.MetadataEvent import com.vitorpamplona.quartz.nip01Core.checkSignature import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.tagValueContains -import com.vitorpamplona.quartz.nip01Core.hasValidSignature +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.addressables.mapTaggedAddress import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip01Core.tags.events.forEachTaggedEventId import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.events.mapTaggedEventId import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers +import com.vitorpamplona.quartz.nip01Core.verify import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip03Timestamp.VerificationState -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEncryptedFileHeaderEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull @@ -82,18 +85,18 @@ import com.vitorpamplona.quartz.nip19Bech32.isATag import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelHideMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.ChannelListEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMuteUserEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip34Git.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.GitReplyEvent -import com.vitorpamplona.quartz.nip34Git.GitRepositoryEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent @@ -113,8 +116,8 @@ import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -122,27 +125,28 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.CommunityListEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppRecommendationEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -import com.vitorpamplona.quartz.nip96FileStorage.FileServersEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils @@ -272,6 +276,8 @@ object LocalCache { fun getAddressableNoteIfExists(key: String): AddressableNote? = addressables.get(key) + fun getAddressableNoteIfExists(address: Address): AddressableNote? = getAddressableNoteIfExists(address.toValue()) + fun getNoteIfExists(key: String): Note? = addressables.get(key) ?: notes.get(key) fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId) @@ -312,7 +318,7 @@ object LocalCache { val noteEvent = note.event return if (noteEvent is AddressableEvent) { // upgrade to the latest - val newNote = checkGetOrCreateAddressableNote(noteEvent.address().toTag()) + val newNote = checkGetOrCreateAddressableNote(noteEvent.aTag().toTag()) if (newNote != null && newNote.event == null) { val author = note.author ?: getOrCreateUser(noteEvent.pubKey) @@ -366,9 +372,10 @@ object LocalCache { if (isValidHex(key)) { return channels.getOrCreate(key) { PublicChatChannel(key) } } - val aTag = ATag.parse(key, null) - if (aTag != null) { - return channels.getOrCreate(aTag.toTag()) { LiveActivitiesChannel(aTag) } + + val address = Address.parse(key) + if (address != null) { + return channels.getOrCreate(address.toValue()) { LiveActivitiesChannel(address) } } return null } @@ -382,7 +389,7 @@ object LocalCache { fun checkGetOrCreateAddressableNote(key: String): AddressableNote? = try { - val addr = ATag.parse(key, null) // relay doesn't matter for the index. + val addr = Address.parse(key) if (addr != null) { getOrCreateAddressableNote(addr) } else { @@ -393,17 +400,12 @@ object LocalCache { null } - fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote { - // checkNotInMainThread() - - // we can't use naddr here because naddr might include relay info and - // the preferred relay should not be part of the index. - return addressables.getOrCreate(key.toTag()) { + fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = + addressables.getOrCreate(key.toValue()) { AddressableNote(key) } - } - fun getOrCreateAddressableNote(key: ATag): AddressableNote { + fun getOrCreateAddressableNote(key: Address): AddressableNote { val note = getOrCreateAddressableNoteInternal(key) // Loads the user outside a Syncronized block to avoid blocking if (note.author == null) { @@ -412,13 +414,18 @@ object LocalCache { return note } - fun getOrCreateNote(key: ETag): Note { + fun getOrCreateNote(key: GenericETag): Note { val note = getOrCreateNote(key.eventId) // Loads the user outside a Syncronized block to avoid blocking - val possibleAuthor = key.authorPubKeyHex + val possibleAuthor = key.author if (note.author == null && possibleAuthor != null) { note.author = checkGetOrCreateUser(possibleAuthor) } + val relayHint = key.relay + if (!relayHint.isNullOrBlank()) { + val relay = RelayBriefInfoCache.get(RelayUrlFormatter.normalize(relayHint)) + note.addRelayBrief(relay) + } return note } @@ -521,7 +528,7 @@ object LocalCache { val replyTo = computeReplyTo(event) - if (event is BaseTextNoteEvent && antiSpam.isSpam(event, relay)) { + if (event is BaseThreadedEvent && antiSpam.isSpam(event, relay)) { return } @@ -676,12 +683,14 @@ object LocalCache { is BadgeAwardEvent -> event.awardDefinition().map { getOrCreateAddressableNote(it) } is PrivateDmEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } is RepostEvent -> - event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + event.boostedEventIds().mapNotNull { checkGetOrCreateNote(it) } + + event.boostedAddresses().map { getOrCreateAddressableNote(it) } is GenericRepostEvent -> - event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } - is CommunityPostApprovalEvent -> event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } + event.boostedEventIds().mapNotNull { checkGetOrCreateNote(it) } + + event.boostedAddresses().map { getOrCreateAddressableNote(it) } + is CommunityPostApprovalEvent -> + event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } + + event.approvedAddresses().map { getOrCreateAddressableNote(it) } is ReactionEvent -> event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } @@ -691,7 +700,7 @@ object LocalCache { is ChannelMessageEvent -> event .tagsWithoutCitations() - .filter { it != event.channel() } + .filter { it != event.channelId() } .mapNotNull { checkGetOrCreateNote(it) } is LiveActivitiesChatMessageEvent -> event @@ -731,11 +740,13 @@ object LocalCache { if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, emptyList()) - val channel = - getOrCreateChannel(note.idHex) { LiveActivitiesChannel(note.address) } - as? LiveActivitiesChannel + val channel = getOrCreateChannel(note.idHex) { LiveActivitiesChannel(note.address) } as? LiveActivitiesChannel - val creator = event.host()?.ifBlank { null }?.let { checkGetOrCreateUser(it) } ?: author + if (relay != null) { + channel?.addRelay(relay) + } + + val creator = event.host()?.let { checkGetOrCreateUser(it.pubKey) } ?: author channel?.updateChannelInfo(creator, event, event.createdAt) @@ -1119,7 +1130,7 @@ object LocalCache { } } - val addressList = event.deleteAddressTags() + val addressList = event.deleteAddressIds() val addressSet = addressList.toSet() addressList @@ -1182,7 +1193,7 @@ object LocalCache { getChannelIfExists(it.toTag())?.removeNote(deleteNote) } - (deletedEvent as? TorrentCommentEvent)?.torrent()?.let { + (deletedEvent as? TorrentCommentEvent)?.torrentIds()?.let { getNoteIfExists(it)?.removeReply(deleteNote) } @@ -1284,7 +1295,7 @@ object LocalCache { val author = getOrCreateUser(event.pubKey) - val communities = event.communities() + val communities = event.communityAddresses() val eventsApproved = computeReplyTo(event) val repliesTo = communities.map { getOrCreateAddressableNote(it) } @@ -1353,14 +1364,17 @@ object LocalCache { refreshObservers(note) } - fun consume(event: ChannelCreateEvent) { + fun consume( + event: ChannelCreateEvent, + relay: Relay?, + ) { // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") val oldChannel = getOrCreateChannel(event.id) { PublicChatChannel(it) } val author = getOrCreateUser(event.pubKey) val note = getOrCreateNote(event.id) if (note.event == null) { - oldChannel.addNote(note) + oldChannel.addNote(note, relay) note.loadEvent(event, author, emptyList()) refreshObservers(note) @@ -1371,13 +1385,16 @@ object LocalCache { } if (oldChannel.creator == null || oldChannel.creator == author) { if (oldChannel is PublicChatChannel) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + oldChannel.updateChannelInfo(author, event, event.createdAt) } } } - fun consume(event: ChannelMetadataEvent) { - val channelId = event.channel() + fun consume( + event: ChannelMetadataEvent, + relay: Relay?, + ) { + val channelId = event.channelId() // Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}") if (channelId.isNullOrBlank()) return @@ -1396,7 +1413,7 @@ object LocalCache { val note = getOrCreateNote(event.id) if (note.event == null) { - oldChannel.addNote(note) + oldChannel.addNote(note, relay) note.loadEvent(event, author, emptyList()) refreshObservers(note) @@ -1407,14 +1424,14 @@ object LocalCache { event: ChannelMessageEvent, relay: Relay?, ) { - val channelId = event.channel() + val channelId = event.channelId() if (channelId.isNullOrBlank()) return val channel = checkGetOrCreateChannel(channelId) ?: return val note = getOrCreateNote(event.id) - channel.addNote(note) + channel.addNote(note, relay) val author = getOrCreateUser(event.pubKey) @@ -1452,12 +1469,12 @@ object LocalCache { event: LiveActivitiesChatMessageEvent, relay: Relay?, ) { - val activityId = event.activity() ?: return + val activityAddress = event.activityAddress() ?: return - val channel = getOrCreateChannel(activityId.toTag()) { LiveActivitiesChannel(activityId) } + val channel = getOrCreateChannel(activityAddress.toValue()) { LiveActivitiesChannel(activityAddress) } val note = getOrCreateNote(event.id) - channel.addNote(note) + channel.addNote(note, relay) val author = getOrCreateUser(event.pubKey) @@ -1845,6 +1862,21 @@ object LocalCache { ) } + /** + * Will return true if supplied note is one of events to be excluded from + * search results. + */ + private fun excludeNoteEventFromSearchResults(note: Note): Boolean = + ( + note.event is GenericRepostEvent || + note.event is RepostEvent || + note.event is CommunityPostApprovalEvent || + note.event is ReactionEvent || + note.event is LnZapEvent || + note.event is LnZapRequestEvent || + note.event is FileHeaderEvent + ) + fun findNotesStartingWith( text: String, forAccount: Account, @@ -1855,19 +1887,13 @@ object LocalCache { if (key != null) { val note = getNoteIfExists(key) - if (note != null) { + if ((note != null) && !excludeNoteEventFromSearchResults(note)) { return listOfNotNull(note) } } return notes.filter { _, note -> - if (note.event is GenericRepostEvent || - note.event is RepostEvent || - note.event is CommunityPostApprovalEvent || - note.event is ReactionEvent || - note.event is LnZapEvent || - note.event is LnZapRequestEvent - ) { + if (excludeNoteEventFromSearchResults(note)) { return@filter false } @@ -1892,13 +1918,7 @@ object LocalCache { return@filter false } + addressables.filter { _, addressable -> - if (addressable.event is GenericRepostEvent || - addressable.event is RepostEvent || - addressable.event is CommunityPostApprovalEvent || - addressable.event is ReactionEvent || - addressable.event is LnZapEvent || - addressable.event is LnZapRequestEvent - ) { + if (excludeNoteEventFromSearchResults(addressable)) { return@filter false } @@ -2115,7 +2135,7 @@ object LocalCache { val noteEvent = note.event if (noteEvent is AddressableEvent) { noteEvent.createdAt < - (addressables.get(noteEvent.address().toTag())?.event?.createdAt ?: 0) + (addressables.get(noteEvent.aTag().toTag())?.event?.createdAt ?: 0) } else { false } @@ -2124,7 +2144,7 @@ object LocalCache { val childrenToBeRemoved = mutableListOf() toBeRemoved.forEach { - val newerVersion = (it.event as? AddressableEvent)?.address()?.toTag()?.let { tag -> addressables.get(tag) } + val newerVersion = (it.event as? AddressableEvent)?.aTag()?.toTag()?.let { tag -> addressables.get(tag) } if (newerVersion != null) { it.moveAllReferencesTo(newerVersion) } @@ -2308,7 +2328,7 @@ object LocalCache { fun justVerify(event: Event): Boolean { checkNotInMainThread() - return if (!event.hasValidSignature()) { + return if (!event.verify()) { try { event.checkSignature() } catch (e: Exception) { @@ -2391,9 +2411,9 @@ object LocalCache { } } is ChannelMessageEvent -> { - draft.channel()?.let { channelId -> + draft.channelId()?.let { channelId -> checkGetOrCreateChannel(channelId)?.let { channel -> - channel.addNote(note) + channel.addNote(note, null) } } } @@ -2460,7 +2480,7 @@ object LocalCache { } } is ChannelMessageEvent -> { - draft.channel()?.let { channelId -> + draft.channelId()?.let { channelId -> checkGetOrCreateChannel(channelId)?.let { channel -> channel.removeNote(draftWrap) } @@ -2519,11 +2539,11 @@ object LocalCache { is CalendarDateSlotEvent -> consume(event, relay) is CalendarTimeSlotEvent -> consume(event, relay) is CalendarRSVPEvent -> consume(event, relay) - is ChannelCreateEvent -> consume(event) + is ChannelCreateEvent -> consume(event, relay) is ChannelListEvent -> consume(event, relay) is ChannelHideMessageEvent -> consume(event) is ChannelMessageEvent -> consume(event, relay) - is ChannelMetadataEvent -> consume(event) + is ChannelMetadataEvent -> consume(event, relay) is ChannelMuteUserEvent -> consume(event) is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay) is ChatMessageEvent -> consume(event, relay) 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 152031f671..9868922cad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -37,35 +37,41 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue +import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward import com.vitorpamplona.quartz.lightning.LnInvoiceUtil -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.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.events.EventReference import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -import com.vitorpamplona.quartz.nip19Bech32.toNAddr import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.containsAny @@ -80,11 +86,11 @@ import kotlin.coroutines.resume @Stable class AddressableNote( - val address: ATag, -) : Note(address.toTag()) { - override fun idNote() = address.toNAddr(relayHintUrl()) + val address: Address, +) : Note(address.toValue()) { + override fun idNote() = toNAddr() - override fun toNEvent() = address.toNAddr(relayHintUrl()) + override fun toNEvent() = toNAddr() override fun idDisplayNote() = idNote().toShortenHex() @@ -103,11 +109,15 @@ class AddressableNote( override fun wasOrShouldBeDeletedBy( deletionEvents: Set, - deletionAddressables: Set, + deletionAddressables: Set
, ): Boolean { val thisEvent = event return deletionAddressables.contains(address) || (thisEvent != null && deletionEvents.contains(thisEvent.id)) } + + fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, relayHintUrl()) + + fun toATag() = ATag(address, relayHintUrl()) } @Stable @@ -195,16 +205,16 @@ open class Note( event is LiveActivitiesChatMessageEvent || event is LiveActivitiesEvent ) { - (event as? ChannelMessageEvent)?.channel() - ?: (event as? ChannelMetadataEvent)?.channel() + (event as? ChannelMessageEvent)?.channelId() + ?: (event as? ChannelMetadataEvent)?.channelId() ?: (event as? ChannelCreateEvent)?.id ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() - ?: (event as? LiveActivitiesEvent)?.address()?.toTag() + ?: (event as? LiveActivitiesEvent)?.aTag()?.toTag() } else { null } - open fun address(): ATag? = null + open fun address(): Address? = null open fun createdAt() = event?.createdAt @@ -442,6 +452,13 @@ open class Note( } } + fun addRelayBrief(brief: RelayBriefInfoCache.RelayBriefInfo) { + if (brief !in relays) { + addRelaySync(brief) + flowSet?.relays?.invalidateData() + } + } + private suspend fun isPaidByCalculation( account: Account, zapEvents: List>, @@ -702,7 +719,7 @@ open class Note( fun hasPledgeBy(user: User): Boolean = replies - .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } + .filter { it.event?.hasAdditionalReward() ?: false } .any { val pledgeValue = try { @@ -716,18 +733,7 @@ open class Note( pledgeValue != null && it.author == user } - fun pledgedAmountByOthers(): BigDecimal = - replies - .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } - .mapNotNull { - try { - BigDecimal(it.event?.content) - } catch (e: Exception) { - if (e is CancellationException) throw e - null - // do nothing if it can't convert to bigdecimal - } - }.sumOf { it } + fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal.ZERO } fun hasAnyReports(): Boolean { val dayAgo = TimeUtils.oneDayAgo() @@ -839,7 +845,7 @@ open class Note( } if (accountChoices.hiddenWordsCase.isNotEmpty()) { - if (thisEvent is BaseTextNoteEvent && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) { + if (thisEvent is BaseThreadedEvent && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) { return true } @@ -912,11 +918,41 @@ open class Note( open fun wasOrShouldBeDeletedBy( deletionEvents: Set, - deletionAddressables: Set, + deletionAddressables: Set
, ): Boolean { val thisEvent = event return deletionEvents.contains(idHex) || (thisEvent is AddressableEvent && deletionAddressables.contains(thisEvent.address())) } + + fun toETag(): ETag { + val noteEvent = event + return if (noteEvent != null) { + ETag(noteEvent.id, relayHintUrl(), noteEvent.pubKey) + } else { + ETag(idHex, relayHintUrl(), author?.pubkeyHex) + } + } + + fun toEId(): EventReference { + val noteEvent = event + return if (noteEvent != null) { + // uses the confirmed event id if available + EventReference(noteEvent.id, noteEvent.pubKey, relayHintUrl()) + } else { + EventReference(idHex, author?.pubkeyHex, relayHintUrl()) + } + } + + fun toEventHint() = (event as? T)?.let { EventHintBundle(it, relayHintUrl(), author?.bestRelayHint()) } + + fun toMarkedETag(marker: MarkedETag.MARKER): MarkedETag { + val noteEvent = event + return if (noteEvent != null) { + MarkedETag(noteEvent.id, relayHintUrl(), marker, noteEvent.pubKey) + } else { + MarkedETag(idHex, relayHintUrl(), marker, author?.pubkeyHex) + } + } } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt index 2ddcd534dc..39c9683de3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey class ParticipantListBuilder { private fun addFollowsThatDirectlyParticipateOnToSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt index 25798635a2..cc722dc75f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt @@ -35,6 +35,7 @@ data class Settings( val dontShowPushNotificationSelector: Boolean = false, val dontAskForNotificationPermissions: Boolean = false, val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED, + val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC, ) enum class ThemeType( @@ -75,6 +76,14 @@ enum class FeatureSetType( PERFORMANCE(2, R.string.ui_feature_set_type_performance), } +enum class ProfileGalleryType( + val screenCode: Int, + val resourceId: Int, +) { + CLASSIC(0, R.string.gallery_type_classic), + MODERN(1, R.string.gallery_type_modern), +} + fun parseConnectivityType(code: Boolean?): ConnectivityType = when (code) { ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS @@ -105,6 +114,15 @@ fun parseFeatureSetType(screenCode: Int): FeatureSetType = } } +fun parseGalleryType(screenCode: Int): ProfileGalleryType = + when (screenCode) { + ProfileGalleryType.CLASSIC.screenCode -> ProfileGalleryType.CLASSIC + ProfileGalleryType.MODERN.screenCode -> ProfileGalleryType.MODERN + else -> { + ProfileGalleryType.CLASSIC + } + } + enum class BooleanType( val prefCode: Boolean?, val screenCode: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index 0284c1704d..0a7b9e5b03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import kotlinx.collections.immutable.ImmutableSet @@ -132,7 +132,7 @@ class ThreadAssembler { } class OnlyLatestVersionSet : MutableSet { - val map = hashMapOf() + val map = hashMapOf() val set = hashSetOf() override fun add(element: Note): Boolean { @@ -149,7 +149,7 @@ class OnlyLatestVersionSet : MutableSet { } private fun innerAdd( - address: ATag, + address: Address, element: Note, loadedCreatedAt: Long, ): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt index f8d424e532..4dba1c2730 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import java.lang.Long.min 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 7847977b3f..e033aadfab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -32,15 +32,16 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.ammolite.relays.filters.EOSETime import com.vitorpamplona.quartz.lightning.Lud06 -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.UserMetadata +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent @@ -88,14 +89,15 @@ class User( fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() - fun toNProfile(): String { - val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent)?.writeRelays() + fun authorRelayList() = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent) - return NProfile.create( - pubkeyHex, - relayList?.take(3) ?: listOfNotNull(latestMetadataRelay), - ) - } + fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) + + fun relayHints() = authorRelayList()?.writeRelays()?.take(3) ?: listOfNotNull(latestMetadataRelay) + + fun bestRelayHint() = authorRelayList()?.writeRelays()?.firstOrNull() ?: latestMetadataRelay + + fun toPTag() = PTag(pubkeyHex, bestRelayHint()) fun toNostrUri() = "nostr:${toNProfile()}" @@ -240,16 +242,13 @@ class User( }.flatten() @Synchronized - private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom { - checkNotInMainThread() - - return privateChatrooms[key] + 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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt index ff5312ab79..49185405b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt @@ -44,7 +44,7 @@ abstract class AmethystNostrDataSource( val note = LocalCache.getNoteIfExists(eventId) val noteEvent = note?.event if (noteEvent is AddressableEvent) { - LocalCache.getAddressableNoteIfExists(noteEvent.address().toTag())?.addRelay(relay) + LocalCache.getAddressableNoteIfExists(noteEvent.aTag().toTag())?.addRelay(relay) } else { note?.addRelay(relay) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt index 91b3ad9dcc..d6a5520a57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt @@ -34,8 +34,8 @@ import coil3.key.Keyer import coil3.request.ImageRequest import coil3.request.Options import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 import java.util.Base64 @Stable @@ -83,7 +83,7 @@ class Base64Fetcher( options: Options, ): String? = if (data.scheme == "data") { - CryptoUtils.sha256(data.toString().toByteArray()).toHexKey() + sha256(data.toString().toByteArray()).toHexKey() } else { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index a2d20134a6..165995c30c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.serialization.ExperimentalSerializationApi diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt index 001c0faea7..36433e85aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2 @@ -125,5 +126,9 @@ fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists): String { } } + if (EmojiCoder.isCoded(this)) { + return EmojiCoder.cropToFirstMessage(this) + } + return firstFullChar() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt index 97ed4ae069..54c8433d3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt @@ -34,6 +34,7 @@ import com.fonfon.kgeohash.GeoHash import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_DISTANCE import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_TIME +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -125,7 +126,7 @@ class LocationState( (context) .get(MIN_TIME, MIN_DISTANCE) .map { - LocationResult.Success(it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits)) as LocationResult + LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult }.onEach { latestLocation = it }.catch { e -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 1d744cea09..ff03bb5596 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -35,21 +35,21 @@ import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.MetadataEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip34Git.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.GitReplyEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent @@ -64,12 +64,12 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip96FileStorage.FileServersEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.utils.TimeUtils // TODO: Migrate this to a property of AccountVi diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt index ec7320cbb8..310b9d14f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") { var account: Account? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt index 0d2ca1d9e6..130c17a379 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") { lateinit var account: Account diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index 5e8f13eb07..37fc97c84b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -26,10 +26,10 @@ import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { lateinit var account: Account diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt index e57b4c3f98..70e74ee5bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt @@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed") { private var communityToWatch: AddressableNote? = null @@ -38,7 +38,7 @@ object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed") val authors = community .moderators() - .map { it.key } + .map { it.pubKey } .plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)) if (authors.isEmpty()) return null @@ -50,7 +50,7 @@ object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed") authors = authors, tags = mapOf( - "a" to listOf(myCommunityToWatch.address.toTag()), + "a" to listOf(myCommunityToWatch.address.toValue()), ), kinds = listOf(CommunityPostApprovalEvent.KIND), limit = 500, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index 0e14b62bfe..6fbad76eca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -27,14 +27,14 @@ import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt index 413d9a4f7d..dc8f1b73d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt @@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index 9f81afd36a..6e238a4574 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -23,15 +23,15 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index ca6527a1fa..a6c0d40663 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -27,22 +27,22 @@ import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index b38c6ab1b4..0b8a3ee7b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -24,19 +24,19 @@ import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.toHexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent @@ -44,19 +44,18 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec -import com.vitorpamplona.quartz.nip19Bech32.parse import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.Hex @@ -81,7 +80,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe } when (val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity) { - is NSec -> KeyPair(privKey = parsed.hex.bechToBytes()).pubKey.toHexKey() + is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey() is NPub -> parsed.hex is NProfile -> parsed.hex is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> parsed.hex diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt index 39bbf93555..93894a463f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed") { private var channelsToWatch = setOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 1cc9266f9c..7ba8259dac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -35,12 +35,12 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip34Git.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent @@ -81,7 +81,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { CommunityPostApprovalEvent.KIND, LiveActivitiesChatMessageEvent.KIND, ), - tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }), + tags = mapOf("a" to it.mapNotNull { it.address()?.toValue() }), since = findMinimumEOSEs(it), // Max amount of "replies" to download on a specific event. limit = 1000, @@ -95,7 +95,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { listOf( DeletionEvent.KIND, ), - tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }), + tags = mapOf("a" to it.mapNotNull { it.address()?.toValue() }), since = findMinimumEOSEs(it), // Max amount of "replies" to download on a specific event. limit = 10, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index d79108c8ab..8e0f28c1bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.EOSETime import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent -import com.vitorpamplona.quartz.nip01Core.MetadataEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 9a7a9e0335..f07dcfd243 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -24,12 +24,12 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -49,7 +49,7 @@ import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppRecommendationEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { var user: User? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index 7fbb2f3869..f0a0fa46c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index e00932f21c..1fd8570497 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -25,7 +25,7 @@ import android.util.LruCache import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager -import com.vitorpamplona.quartz.CryptoUtils +import com.vitorpamplona.quartz.utils.RandomInstance import okhttp3.EventListener import okhttp3.Protocol import okhttp3.Request @@ -70,7 +70,7 @@ object OnlineChecker { .url(url.replace("wss+livekit://", "wss://")) .header("Upgrade", "websocket") .header("Connection", "Upgrade") - .header("Sec-WebSocket-Key", CryptoUtils.random(16).toByteString().base64()) + .header("Sec-WebSocket-Key", RandomInstance.bytes(16).toByteString().base64()) .header("Sec-WebSocket-Version", "13") .header("Sec-WebSocket-Extensions", "permessage-deflate") .build() 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 252f90d349..b089043fbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -32,14 +32,14 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.BaseZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -77,7 +77,7 @@ class ZapPaymentHandler( if (!zapSplitSetup.isNullOrEmpty()) { zapSplitSetup } else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) { - noteEvent.hosts().map { ZapSplitSetup(it.pubKeyHex, it.relay, weight = 1.0) } + noteEvent.hosts().map { ZapSplitSetup(it.pubKey, it.relayHint, weight = 1.0) } } else if (noteEvent is AppDefinitionEvent) { val appLud16 = noteEvent.appMetaData()?.lnAddress() if (appLud16 != 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 bf6b5990a5..0d662a63d6 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 @@ -37,18 +37,18 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEncryptedFileHeaderEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEvent +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils import java.math.BigDecimal import kotlin.coroutines.cancellation.CancellationException @@ -137,7 +137,7 @@ class EventNotificationConsumer( Log.d(TAG, "New Notification Arrived") if (!LocalCache.justVerify(event)) return - val users = event.taggedUsers().map { LocalCache.getOrCreateUser(it) } + val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) } val npubs = users.map { it.pubkeyNpub() }.toSet() // PushNotification Wraps don't include a receiver. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt index 07aa7e2a5d..789e16c1e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt @@ -20,51 +20,85 @@ */ package com.vitorpamplona.amethyst.service.okhttp -import android.util.Log -import com.vitorpamplona.quartz.nip17Dm.AESGCM -import com.vitorpamplona.quartz.nip17Dm.NostrCipher +import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody class EncryptedBlobInterceptor( val cache: EncryptionKeyCache, ) : Interceptor { - fun Response.decrypt(cipher: NostrCipher): Response { + private fun Response.decryptOrNullWithErrorCorrection(info: DecryptInformation): Response? { val body = peekBody(Long.MAX_VALUE) - val decryptedBytes = cipher.decrypt(body.bytes()) - val newBody = decryptedBytes.toResponseBody(body.contentType()) - return newBuilder().body(newBody).build() - } - fun Response.decryptOrNull(cipher: NostrCipher): Response? = - try { - decrypt(cipher) - } catch (e: Exception) { - Log.w("EncryptedBlobInterceptor", "Failed to decrypt", e) - null + // Only tries to decrypt if the content-type is a byte array + if (body.contentType().toString() != "application/octet-stream") { + return null } - private fun Response.decryptOrNullWithErrorCorrection(cipher: NostrCipher): Response? { - return decryptOrNull(cipher) ?: return if (cipher is AESGCM) { - decryptOrNull(cipher.copyUsingUTF8Nonce()) - } else { - null + val bytes = body.bytes() + + // Tries the correct way first + // if it fails, tries to decrypt as UTF8 nonce, which was how + // 0xChat started encrypting + val decrypted = + info.cipher.decryptOrNull(bytes) ?: if (info.cipher is AESGCM) { + info.cipher.copyUsingUTF8Nonce().decryptOrNull(bytes) + } else { + null + } + + if (decrypted == null) { + return null } + + return newBuilder() + .apply { + body( + decrypted.toResponseBody( + info.mimeType?.toMediaTypeOrNull() ?: body.contentType(), + ), + ) + // removes hints that would make the app requrest partial byte arrays + // in videos, which are impossible to decrypt. + removeHeader("accept-ranges") + // Fixes the size of the body array + header("content-length", decrypted.size.toString()) + // Trusts the mimetype from the event is better than the mimetype from the server + info.mimeType?.let { header("content-type", it) } + }.build() } override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() - val response = chain.proceed(request) + val encryptionKeys = cache.get(request.url.toString()) - val cipher = cache.get(request.url.toString()) ?: return response + // We cannot use Range requests (partial byte arrays) + // in encrypted payloads because we won't be able to + // decrypt partial byte arrays. + val newRequest = + if (encryptionKeys != null) { + request + .newBuilder() + .removeHeader("Range") + .build() + } else { + request + } + + val response = chain.proceed(newRequest) + + if (encryptionKeys == null) { + return response + } if (response.isSuccessful) { - return response.decryptOrNullWithErrorCorrection(cipher) ?: response + return response.decryptOrNullWithErrorCorrection(encryptionKeys) ?: response } else { // Log redirections to be able to use the cipher. response.header("Location")?.let { - cache.add(it, cipher) + cache.add(it, encryptionKeys) } } return response diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt index 190bb64eab..6677491e13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.okhttp import android.util.LruCache -import com.vitorpamplona.quartz.nip17Dm.NostrCipher +import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher /** * Neigther ExoPlayer, nor Coil support passing key and nonce to the Interceptor via @@ -30,16 +30,27 @@ import com.vitorpamplona.quartz.nip17Dm.NostrCipher * This class serves as a key cache to decrypt the body of HTTP calls that need it. */ class EncryptionKeyCache { - val cache = LruCache(100) + val cache = LruCache(100) + + fun add( + url: String?, + decryptInformation: DecryptInformation, + ) { + if (cache.get(url) == null) { + cache.put(url, decryptInformation) + } + } fun add( url: String?, cipher: NostrCipher, - ) { - if (cache.get(url) == null) { - cache.put(url, cipher) - } - } + expectedMimeType: String?, + ) = add(url, DecryptInformation(cipher, expectedMimeType)) - fun get(url: String): NostrCipher? = cache.get(url) + fun get(url: String): DecryptInformation? = cache.get(url) } + +class DecryptInformation( + val cipher: NostrCipher, + val mimeType: String?, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt index a3aae079aa..f1ffc9174e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.okhttp import android.util.Log -import com.vitorpamplona.quartz.nip17Dm.NostrCipher +import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy @@ -124,5 +124,6 @@ object HttpClientManager { fun addCipherToCache( url: String, cipher: NostrCipher, - ) = cache.add(url, cipher) + expectedMimeType: String?, + ) = cache.add(url, cipher, expectedMimeType) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 57c2fb6a7b..f2f48552d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.service.okhttp -import com.vitorpamplona.quartz.nip01Core.relays.sockets.WebSocket -import com.vitorpamplona.quartz.nip01Core.relays.sockets.WebSocketListener -import com.vitorpamplona.quartz.nip01Core.relays.sockets.WebsocketBuilder -import com.vitorpamplona.quartz.nip01Core.relays.sockets.WebsocketBuilderFactory +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory import okhttp3.Request import okhttp3.Response diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt index 954e9b3628..2f8db2a447 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.service.uploads import android.content.Context import android.net.Uri import androidx.core.net.toUri -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.toHexKey -import com.vitorpamplona.quartz.nip17Dm.NostrCipher +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher +import com.vitorpamplona.quartz.utils.sha256.sha256 import java.io.File class EncryptFilesResult( @@ -48,9 +48,9 @@ class EncryptFiles { resolver.openInputStream(inputFile)!!.use { inputStream -> val bytes = inputStream.readBytes() - val originalHash = CryptoUtils.sha256(bytes).toHexKey() + val originalHash = sha256(bytes).toHexKey() val encrypted = cipher.encrypt(bytes) - val encryptedHash = CryptoUtils.sha256(encrypted).toHexKey() + val encryptedHash = sha256(encrypted).toHexKey() encryptedFile.outputStream().use { outputStream -> outputStream.write(encrypted) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index 3ef79122d5..2203ae4599 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -30,9 +30,9 @@ import android.os.Build import android.util.Log import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash import com.vitorpamplona.amethyst.service.Blurhash -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.toHexKey -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.CancellationException import java.io.IOException @@ -40,7 +40,7 @@ class FileHeader( val mimeType: String?, val hash: String, val size: Int, - val dim: Dimension?, + val dim: DimensionTag?, val blurHash: Blurhash?, ) { class UnableToDownload( @@ -51,7 +51,7 @@ class FileHeader( suspend fun prepare( fileUrl: String, mimeType: String?, - dimPrecomputed: Dimension?, + dimPrecomputed: DimensionTag?, forceProxy: Boolean, ): Result = try { @@ -71,10 +71,10 @@ class FileHeader( fun prepare( data: ByteArray, mimeType: String?, - dimPrecomputed: Dimension?, + dimPrecomputed: DimensionTag?, ): Result = try { - val hash = CryptoUtils.sha256(data).toHexKey() + val hash = sha256(data).toHexKey() val size = data.size val (blurHash, dim) = @@ -82,7 +82,7 @@ class FileHeader( val opt = BitmapFactory.Options() opt.inPreferredConfig = Bitmap.Config.ARGB_8888 val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) - Pair(Blurhash(mBitmap.toBlurhash()), Dimension(mBitmap.width, mBitmap.height)) + Pair(Blurhash(mBitmap.toBlurhash()), DimensionTag(mBitmap.width, mBitmap.height)) } else if (mimeType?.startsWith("video/") == true) { val mediaMetadataRetriever = MediaMetadataRetriever() mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) @@ -135,12 +135,12 @@ fun MediaMetadataRetriever.getThumbnail(): Bitmap? { } } -fun MediaMetadataRetriever.prepareDimFromVideo(): Dimension? { +fun MediaMetadataRetriever.prepareDimFromVideo(): DimensionTag? { val width = prepareVideoWidth() ?: return null val height = prepareVideoHeight() ?: return null return if (width > 0 && height > 0) { - Dimension(width, height) + DimensionTag(width, height) } else { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt index a3a3a7dc04..347d374275 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.service.uploads -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag data class MediaUploadResult( // A publicly accessible URL to the BUD-01 GET / endpoint (optionally with a file extension) @@ -35,7 +35,7 @@ data class MediaUploadResult( // upload time val uploaded: Long? = null, // dimensions - val dimension: Dimension? = null, + val dimension: DimensionTag? = null, // magnet link val magnet: String? = null, // info hash 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 16da96e91f..27ee4b75e9 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 @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Account 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.NostrCipher +import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.joinAll @@ -49,7 +49,7 @@ class MultiOrchestrator( suspend fun upload( scope: CoroutineScope, alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, mediaQuality: CompressorQuality, server: ServerName, account: Account, @@ -62,7 +62,7 @@ class MultiOrchestrator( item.media.uri, item.media.mimeType, alt, - sensitiveContent, + contentWarningReason, mediaQuality, server, account, @@ -79,7 +79,7 @@ class MultiOrchestrator( suspend fun uploadEncrypted( scope: CoroutineScope, alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, mediaQuality: CompressorQuality, cipher: NostrCipher, server: ServerName, @@ -93,7 +93,7 @@ class MultiOrchestrator( item.media.uri, item.media.mimeType, alt, - sensitiveContent, + contentWarningReason, mediaQuality, cipher, server, @@ -126,5 +126,5 @@ class MultiOrchestrator( fun size() = list.size - fun get(index: Int) = list.get(index) + fun get(index: Int) = list[index] } 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 b5d41763c8..bbb09c70fa 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 @@ -29,7 +29,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.nip17Dm.NostrCipher +import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlin.coroutines.cancellation.CancellationException @@ -132,7 +132,7 @@ class UploadOrchestrator { contentType: String?, size: Long?, alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, serverBaseUrl: String, contentTypeForResult: String?, originalHash: String?, @@ -147,7 +147,7 @@ class UploadOrchestrator { contentType = contentType, size = size, alt = alt, - sensitiveContent = if (sensitiveContent) "" else null, + sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, forceProxy = account::shouldUseTorForNIP96, onProgress = { percent: Float -> @@ -175,7 +175,7 @@ class UploadOrchestrator { contentType: String?, size: Long?, alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, serverBaseUrl: String, contentTypeForResult: String?, originalHash: String?, @@ -191,7 +191,7 @@ class UploadOrchestrator { contentType = contentType, size = size, alt = alt, - sensitiveContent = if (sensitiveContent) "" else null, + sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, forceProxy = account::shouldUseTorForNIP96, httpAuth = account::createBlossomUploadAuth, @@ -292,7 +292,7 @@ class UploadOrchestrator { uri: Uri, mimeType: String?, alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, compressionQuality: CompressorQuality, server: ServerName, account: Account, @@ -302,8 +302,8 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context) - ServerType.NIP96 -> uploadNIP96(compressed.uri, compressed.contentType, compressed.size, alt, sensitiveContent, server.baseUrl, null, null, account, context) - ServerType.Blossom -> uploadBlossom(compressed.uri, compressed.contentType, compressed.size, alt, sensitiveContent, server.baseUrl, null, null, account, context) + ServerType.NIP96 -> uploadNIP96(compressed.uri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + ServerType.Blossom -> uploadBlossom(compressed.uri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) } } @@ -311,7 +311,7 @@ class UploadOrchestrator { uri: Uri, mimeType: String?, alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, compressionQuality: CompressorQuality, encrypt: NostrCipher, server: ServerName, @@ -323,8 +323,8 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) - ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, sensitiveContent, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, sensitiveContent, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 49cc0fc794..8e76e1a1bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -35,10 +35,10 @@ import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.service.uploads.nip96.randomChars import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.CryptoUtils import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody @@ -88,7 +88,7 @@ class BlossomUploader { checkNotNull(payload) { "Can't open the image input stream" } - val hash = CryptoUtils.sha256(payload).toHexKey() + val hash = sha256(payload).toHexKey() val imageInputStream = contentResolver.openInputStream(uri) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 7d1ad2e080..1ccc4b3a09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -34,13 +34,12 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip36SensitiveContent.CONTENT_WARNING -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension -import com.vitorpamplona.quartz.nip96FileStorage.AuthToken -import com.vitorpamplona.quartz.nip96FileStorage.Nip96Result -import com.vitorpamplona.quartz.nip96FileStorage.PartialEvent -import com.vitorpamplona.quartz.nip96FileStorage.ResultParser -import com.vitorpamplona.quartz.nip96FileStorage.ServerInfo +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip96FileStorage.actions.DeleteResult +import com.vitorpamplona.quartz.nip96FileStorage.actions.PartialEvent +import com.vitorpamplona.quartz.nip96FileStorage.actions.UploadResult +import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import kotlinx.coroutines.delay import okhttp3.MediaType.Companion.toMediaType @@ -153,7 +152,7 @@ class Nip96Uploader { .addFormDataPart("size", length.toString()) .also { body -> alt?.ifBlank { null }?.let { body.addFormDataPart("alt", it) } - sensitiveContent?.let { body.addFormDataPart(CONTENT_WARNING, it) } + sensitiveContent?.let { body.addFormDataPart(ContentWarningTag.TAG_NAME, it) } contentType?.let { body.addFormDataPart("content_type", it) } }.addFormDataPart( "file", @@ -169,7 +168,7 @@ class Nip96Uploader { }, ).build() - httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", AuthToken().encodeAuth(it)) } + httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", it.toAuthToken()) } requestBuilder .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") @@ -181,7 +180,7 @@ class Nip96Uploader { client.newCall(request).execute().use { response -> if (response.isSuccessful) { response.body.use { body -> - val result = ResultParser().parseResults(body.string()) + val result = UploadResult.parse(body.string()) if (!result.processingUrl.isNullOrBlank()) { return waitProcessing(result, server, forceProxy, onProgress) } else if (result.status == "success") { @@ -244,7 +243,7 @@ class Nip96Uploader { ?.firstOrNull { it.size > 1 && it[0] == "dim" } ?.get(1) ?.ifBlank { null } - ?.let { Dimension.parse(it) } + ?.let { DimensionTag.parse(it) } val magnet = nip96.tags ?.firstOrNull { it.size > 1 && it[0] == "magnet" } @@ -265,7 +264,7 @@ class Nip96Uploader { contentType: String?, server: ServerInfo, forceProxy: (String) -> Boolean, - httpAuth: (String, String, ByteArray?) -> HTTPAuthorizationEvent, + httpAuth: (String, String, ByteArray?) -> HTTPAuthorizationEvent?, context: Context, ): Boolean { val extension = @@ -275,7 +274,7 @@ class Nip96Uploader { val requestBuilder = Request.Builder() - httpAuth(server.apiUrl, "DELETE", null)?.let { requestBuilder.addHeader("Authorization", AuthToken().encodeAuth(it)) } + httpAuth(server.apiUrl, "DELETE", null)?.let { requestBuilder.addHeader("Authorization", it.toAuthToken()) } val request = requestBuilder @@ -287,7 +286,7 @@ class Nip96Uploader { client.newCall(request).execute().use { response -> if (response.isSuccessful) { response.body.use { body -> - val result = ResultParser().parseDeleteResults(body.string()) + val result = DeleteResult.parse(body.string()) return result.status == "success" } } else { @@ -302,7 +301,7 @@ class Nip96Uploader { } private suspend fun waitProcessing( - result: Nip96Result, + result: UploadResult, server: ServerInfo, forceProxy: (String) -> Boolean, onProgress: (percentage: Float) -> Unit, @@ -324,7 +323,7 @@ class Nip96Uploader { val client = HttpClientManager.getHttpClient(forceProxy(procUrl)) client.newCall(request).execute().use { if (it.isSuccessful) { - it.body.use { currentResult = ResultParser().parseResults(it.string()) } + it.body.use { currentResult = UploadResult.parse(it.string()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt index b100bf1fb1..d95119a312 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 @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service.uploads.nip96 import android.util.Log import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager -import com.vitorpamplona.quartz.nip96FileStorage.ServerInfo -import com.vitorpamplona.quartz.nip96FileStorage.ServerInfoParser +import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo +import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfoParser import kotlinx.coroutines.CancellationException import okhttp3.Request 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 6d6902cd32..8f363caa3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -47,7 +47,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.tor.TorManager -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed @@ -55,12 +55,12 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.Note -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers 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 a9f56d0f62..aac0d72968 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 @@ -46,10 +46,19 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.quartz.experimental.nip95.FileStorageEvent -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.alt +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dims +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent +import com.vitorpamplona.quartz.nip94FileMetadata.size import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -165,7 +174,7 @@ open class EditPostViewModel : ViewModel() { myMultiOrchestrator.upload( viewModelScope, alt, - sensitiveContent, + if (sensitiveContent) "" else null, MediaCompressor.intToCompressorQuality(mediaQuality), server, myAccount, @@ -175,7 +184,12 @@ 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, sensitiveContent) { nip95 -> + 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index b378aa62f1..8caff509f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -81,7 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.ChannelName import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness 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 6bf5b480d5..c005df88cc 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 @@ -58,17 +58,18 @@ object MediaSaverToDisk { if (videoUri != null) { if (!videoUri.startsWith("file")) { downloadAndSave( + url = videoUri, + mimeType = mimeType, context = localContext, forceProxy = forceProxy, - url = videoUri, onSuccess = onSuccess, onError = onError, ) } else { save( - context = localContext, localFile = videoUri.toUri().toFile(), mimeType = mimeType, + context = localContext, onSuccess = onSuccess, onError = onError, ) @@ -83,6 +84,7 @@ object MediaSaverToDisk { */ fun downloadAndSave( url: String, + mimeType: String?, forceProxy: Boolean, context: Context, onSuccess: () -> Any?, @@ -121,9 +123,16 @@ object MediaSaverToDisk { val contentType = response.header("Content-Type") checkNotNull(contentType) { "Can't find out the content type" } + val realType = + if (mimeType != null && contentType == "application/octet-stream") { + mimeType + } else { + contentType + } + saveContentQ( displayName = File(url).nameWithoutExtension, - contentType = contentType, + contentType = realType, contentSource = response.body.source(), contentResolver = context.contentResolver, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt index 25b42c4fe7..de37917fdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt @@ -26,6 +26,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -53,19 +57,44 @@ class NewChannelViewModel : ViewModel() { fun create() { viewModelScope.launch(Dispatchers.IO) { account?.let { account -> - if (originalChannel == null) { - account.sendCreateNewChannel( - channelName.value.text, - channelDescription.value.text, - channelPicture.value.text, - ) + val channel = originalChannel + if (channel == null) { + val template = + ChannelCreateEvent.build( + channelName.value.text, + channelDescription.value.text, + channelPicture.value.text, + null, + ) + + account.sendCreateNewChannel(template) } else { - account.sendChangeChannel( - channelName.value.text, - channelDescription.value.text, - channelPicture.value.text, - originalChannel!!, - ) + val event = channel.event + + val template = + if (event != null) { + val hint = EventHintBundle(event, channel.relays().firstOrNull()) + + ChannelMetadataEvent.build( + channelName.value.text, + channelDescription.value.text, + channelPicture.value.text, + null, + hint, + ) + } else { + val eTag = ETag(channel.idHex, channel.relays().firstOrNull()) + + ChannelMetadataEvent.build( + channelName.value.text, + channelDescription.value.text, + channelPicture.value.text, + null, + eTag, + ) + } + + account.sendChangeChannel(template) } } 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 f818db786d..2f096d38e3 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 @@ -99,7 +99,7 @@ open class NewMediaModel : ViewModel() { myMultiOrchestrator.upload( viewModelScope, caption, - sensitiveContent, + if (sensitiveContent) "" else null, MediaCompressor.intToCompressorQuality(mediaQualitySlider), serverToUse, myAccount, @@ -140,7 +140,7 @@ open class NewMediaModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { withTimeoutOrNull(30000) { suspendCancellableCoroutine { continuation -> - account?.createNip95(it.bytes, headerInfo = it.fileHeader, caption, sensitiveContent) { nip95 -> + account?.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null) { nip95 -> account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) continuation.resume(true) } @@ -160,7 +160,7 @@ open class NewMediaModel : ViewModel() { it.magnet, it.fileHeader, caption, - sensitiveContent, + if (sensitiveContent) "" else null, it.uploadedHash, relayList, ) { @@ -180,7 +180,7 @@ open class NewMediaModel : ViewModel() { account?.sendAllAsOnePictureEvent( imageUrls, caption, - sensitiveContent, + if (sensitiveContent) "" else null, relayList, ) { continuation.resume(true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 3f613da331..4b7eb7bdcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @@ -180,7 +180,7 @@ class NewMessageTagger( val restOfWord = key.substring(63) // Converts to npub val pubkey = - Nip19Parser.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null + Nip19Parser.uriToRoute(Nip01.pubKeyCreate(keyB32.bechToBytes()).toNpub()) ?: return null return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null }) } else if (key.startsWith("npub1", true)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt index 8c8a61c203..0f146f7b75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt @@ -52,7 +52,7 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) { pollViewModel.isValidClosedAt.value = true if (text.isNotEmpty()) { try { - val int = text.toInt() + val int = text.toLong() if (int < 0) { pollViewModel.isValidClosedAt.value = false } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index d92db895f2..6559d63d39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -38,9 +38,12 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.imageExtensions import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource @@ -55,42 +58,85 @@ import com.vitorpamplona.amethyst.ui.components.Split import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.quartz.experimental.nip95.FileStorageEvent -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.closedAt +import com.vitorpamplona.quartz.experimental.zapPolls.consensusThreshold +import com.vitorpamplona.quartz.experimental.zapPolls.maxAmount +import com.vitorpamplona.quartz.experimental.zapPolls.minAmount +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.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTags +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip10Notes.tags.notify +import com.vitorpamplona.quartz.nip10Notes.tags.positionalMarkedTags import com.vitorpamplona.quartz.nip14Subject.subject -import com.vitorpamplona.quartz.nip17Dm.NIP17Group +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip22Comments.notify +import com.vitorpamplona.quartz.nip28PublicChat.base.notify +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip34Git.GitIssueEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.reply.notify import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.notify 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.nip57Zaps.splits.zapSplits +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.alt +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dims +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent +import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.nip99Classifieds.Price +import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag import com.vitorpamplona.quartz.utils.Hex import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -171,10 +217,10 @@ open class NewPostViewModel : ViewModel() { var wantsPoll by mutableStateOf(false) var zapRecipients = mutableStateListOf() var pollOptions = newStateMapPollOptions() - var valueMaximum by mutableStateOf(null) - var valueMinimum by mutableStateOf(null) + var valueMaximum by mutableStateOf(null) + var valueMinimum by mutableStateOf(null) var consensusThreshold: Int? = null - var closedAt: Int? = null + var closedAt: Long? = null var isValidRecipients = mutableStateOf(true) var isValidvalueMaximum = mutableStateOf(true) @@ -188,13 +234,14 @@ open class NewPostViewModel : ViewModel() { var price by mutableStateOf(TextFieldValue("")) var locationText by mutableStateOf(TextFieldValue("")) var category by mutableStateOf(TextFieldValue("")) - var condition by - mutableStateOf(ClassifiedsEvent.CONDITION.USED_LIKE_NEW) + var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) // Invoices var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) + var wantsSecretEmoji by mutableStateOf(false) + // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) var forwardZapTo by mutableStateOf>(Split()) @@ -253,7 +300,7 @@ open class NewPostViewModel : ViewModel() { } else { originalNote = replyingTo replyingTo?.let { replyNote -> - if (replyNote.event is BaseTextNoteEvent) { + if (replyNote.event is BaseThreadedEvent) { this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) } else { this.eTags = listOf(replyNote) @@ -264,8 +311,7 @@ open class NewPostViewModel : ViewModel() { val currentMentions = (replyNote.event as? TextNoteEvent) ?.mentions() - ?.filter { it.isNotEmpty() } - ?.map { LocalCache.getOrCreateUser(it) } + ?.map { LocalCache.getOrCreateUser(it.pubKey) } ?: emptyList() if (currentMentions.contains(replyUser)) { @@ -283,7 +329,7 @@ open class NewPostViewModel : ViewModel() { canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null - canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null + canUsePoll = originalNote == null multiOrchestrator = null quote?.let { @@ -431,7 +477,7 @@ open class NewPostViewModel : ViewModel() { }.firstOrNull() } - canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null + canUsePoll = originalNote == null if (forwardZapTo.items.isNotEmpty()) { wantsForwardZapTo = true @@ -447,9 +493,9 @@ open class NewPostViewModel : ViewModel() { val minMax = draftEvent.tags.filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") } minMax.forEach { if (it[0] == "value_maximum") { - valueMaximum = it[1].toInt() + valueMaximum = it[1].toLong() } else if (it[0] == "value_minimum") { - valueMinimum = it[1].toInt() + valueMinimum = it[1].toLong() } } @@ -487,14 +533,14 @@ open class NewPostViewModel : ViewModel() { .map { it[1] } ?.firstOrNull() ?: "", ) - condition = ClassifiedsEvent.CONDITION.entries.firstOrNull { + condition = ConditionTag.CONDITION.entries.firstOrNull { it.value == draftEvent .tags .filter { it.size > 1 && it[0] == "condition" } .map { it[1] } .firstOrNull() - } ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW + } ?: ConditionTag.CONDITION.USED_LIKE_NEW wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is NIP17Group @@ -533,13 +579,17 @@ open class NewPostViewModel : ViewModel() { } fun sendDraft(relayList: List) { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { sendDraftSync(relayList) } } suspend fun sendDraftSync(relayList: List) { - innerSendPost(relayList, draftTag) + if (message.text.isBlank()) { + account?.deleteDraft(draftTag) + } else { + innerSendPost(relayList, draftTag) + } } private suspend fun innerSendPost( @@ -594,119 +644,275 @@ open class NewPostViewModel : ViewModel() { val usedAttachments = iMetaAttachments.filter { it.url in urls.toSet() } val replyingTo = originalNote + val contentWarningReason = if (wantsToMarkAsSensitive) "" else null - if (replyingTo?.event is CommentEvent || (replyingTo?.event is Event && replyingTo.event is RootScope)) { - account?.sendReplyComment( - message = tagger.message, - replyingTo = replyingTo, - directMentionsUsers = tagger.directMentionsUsers, - directMentionsNotes = tagger.directMentionsNotes, - imetas = usedAttachments, - geohash = geoHash, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - relayList = relayList, - emojis = emojis, - draftTag = localDraft, - ) - } else if (wantsExclusiveGeoPost && geoHash != null && (originalNote == null || originalNote?.event is CommentEvent)) { - account?.sendGeoComment( - message = tagger.message, - geohash = geoHash, - replyingTo = originalNote, - directMentionsUsers = tagger.directMentionsUsers, - directMentionsNotes = tagger.directMentionsNotes, - imetas = usedAttachments, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - relayList = relayList, - emojis = emojis, - draftTag = localDraft, - ) - } else if (originalNote?.channelHex() != null) { - if (originalNote is AddressableEvent && originalNote?.address() != null) { - account?.sendLiveMessage( - message = tagger.message, - toChannel = originalNote?.address()!!, - replyTo = tagger.eTags, - mentions = tagger.pTags, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) - } else { - account?.sendChannelMessage( - message = tagger.message, - toChannel = tagger.channelHex!!, - replyTo = tagger.eTags, - mentions = tagger.pTags, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - directMentions = tagger.directMentions, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) + val channel = originalNote?.channelHex()?.let { LocalCache.getChannelIfExists(it) } + + if (replyingTo?.event is CommentEvent || replyingTo?.event is RootScope) { + val eventHint = replyingTo.toEventHint() ?: return@withContext + + val template = + CommentEvent.replyBuilder( + msg = tagger.message, + replyingTo = eventHint, + ) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + + account?.signAndSend(localDraft, template, relayList, setOf(replyingTo)) + } else if (wantsExclusiveGeoPost && geoHash != null && originalNote == null) { + val template = + CommentEvent.replyExternalIdentity( + msg = tagger.message, + extId = GeohashId(geoHash), + ) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + + account?.signAndSend(localDraft, template, relayList, emptyList()) + } else if (channel != null) { + if (channel is PublicChatChannel) { + val replyingToEvent = originalNote?.toEventHint() + val channelEvent = channel.event + val channelRelays = channel.relays() + + val template = + if (replyingToEvent != null) { + ChannelMessageEvent.reply(tagger.message, replyingToEvent) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else if (channelEvent != null) { + val hint = EventHintBundle(channelEvent, channelRelays.firstOrNull()) + ChannelMessageEvent.message(tagger.message, hint) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else { + ChannelMessageEvent.message(tagger.message, ETag(channel.idHex, channelRelays.firstOrNull())) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + + val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) + + account?.signAndSendWithList(draftTag, template, channelRelays, broadcast) + } else if (channel is LiveActivitiesChannel) { + val replyingToEvent = originalNote?.toEventHint() + val activity = channel.info + val channelRelays = channel.relays() + + val template = + if (replyingToEvent != null) { + LiveActivitiesChatMessageEvent.reply(tagger.message, replyingToEvent) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else if (activity != null) { + val hint = EventHintBundle(activity, channelRelays.firstOrNull() ?: replyingToEvent?.relay) + + LiveActivitiesChatMessageEvent.message(tagger.message, hint) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else { + LiveActivitiesChatMessageEvent.message(tagger.message, channel.toATag()) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + + val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) + + account?.signAndSendWithList(draftTag, template, channelRelays, broadcast) } } else if (originalNote?.event is PrivateDmEvent) { account?.sendPrivateMessage( message = tagger.message, toUser = originalNote!!.author!!, replyingTo = originalNote!!, - mentions = tagger.pTags, zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, + contentWarningReason = contentWarningReason, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, imetas = usedAttachments, draftTag = localDraft, ) } else if (originalNote?.event is NIP17Group) { - account?.sendNIP17PrivateMessage( - message = tagger.message, - toUsers = (originalNote?.event as NIP17Group).groupMembers().toList(), - subject = subject.text.ifBlank { null }, - replyingTo = originalNote!!, - mentions = tagger.pTags, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapReceiver = zapReceiver, - zapRaiserAmount = localZapRaiserAmount, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) + val replyHint = originalNote?.toEventHint() + + val template = + if (replyHint == null) { + val msgTo = (originalNote?.event as NIP17Group).groupMembers().map { LocalCache.getOrCreateUser(it).toPTag() } + ChatMessageEvent.build(tagger.message, msgTo) { + subject.text.ifBlank { null }?.let { changeSubject(it) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else { + ChatMessageEvent.reply(tagger.message, replyHint) { + subject.text.ifBlank { null }?.let { changeSubject(it) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + + account?.sendNIP17PrivateMessage(template, localDraft) } else if (!dmUsers.isNullOrEmpty()) { if (nip17 || dmUsers.size > 1) { - account?.sendNIP17PrivateMessage( - message = tagger.message, - toUsers = dmUsers.map { it.pubkeyHex }, - subject = subject.text.ifBlank { null }, - replyingTo = tagger.eTags?.firstOrNull(), - mentions = tagger.pTags, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapReceiver = zapReceiver, - zapRaiserAmount = localZapRaiserAmount, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) + val replyHint = originalNote?.toEventHint() + val template = + if (replyHint == null) { + ChatMessageEvent.build(tagger.message, dmUsers.map { it.toPTag() }) { + subject.text.ifBlank { null }?.let { changeSubject(it) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else { + ChatMessageEvent.reply(tagger.message, replyHint) { + subject.text.ifBlank { null }?.let { changeSubject(it) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + + account?.sendNIP17PrivateMessage(template, localDraft) } else { account?.sendPrivateMessage( message = tagger.message, - toUser = dmUsers.first().pubkeyHex, + toUser = dmUsers.first(), replyingTo = originalNote, - mentions = tagger.pTags, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, + contentWarningReason = contentWarningReason, zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, @@ -715,62 +921,50 @@ open class NewPostViewModel : ViewModel() { ) } } else if (originalNote?.event is GitIssueEvent) { - val originalNoteEvent = originalNote?.event as GitIssueEvent - // adds markers - val rootId = - originalNoteEvent.rootIssueOrPatch() // if it has a marker as root - ?: originalNote - ?.replyTo - ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } - ?.idHex // if it has loaded events with zero replies in the reply list - ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. - ?: originalNote?.idHex + val originalNoteHint = originalNote?.toEventHint() ?: return@withContext - val replyId = originalNote?.idHex + val template = + GitReplyEvent.replyIssue( + tagger.message, + originalNoteHint, + ) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } - val replyToSet = - if (forkedFromNote != null) { - (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } - } else { - tagger.eTags + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) } - val repositoryAddress = originalNoteEvent.repository() + val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) - account?.sendGitReply( - message = tagger.message, - replyTo = replyToSet, - mentions = tagger.pTags, - repository = repositoryAddress, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - replyingTo = replyId, - root = rootId, - directMentions = tagger.directMentions, - forkedFrom = forkedFromNote?.event as? Event, - relayList = relayList, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) + account?.signAndSend(localDraft, template, relayList, broadcast) } else if (originalNote?.event is TorrentCommentEvent) { - val originalNoteEvent = originalNote?.event as TorrentCommentEvent - // adds markers - val rootId = - originalNoteEvent.torrent() // if it has a marker as root - ?: originalNote - ?.replyTo - ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } - ?.idHex // if it has loaded events with zero replies in the reply list - ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. - ?: originalNote?.idHex + val replyToEvent = originalNote?.event as TorrentCommentEvent - if (rootId != null) { - // There must be a torrent ID - val replyId = originalNote?.idHex + val rootETag = replyToEvent.torrent() + val rootNote = rootETag?.eventId?.let { LocalCache.getNoteIfExists(it) } + val rootNoteEvent = rootNote?.event + // only uses the root node if the event is loaded. + val root = + if (rootNoteEvent != null) { + rootNote // refreshes author and relay hint to what we have. + } else { + rootETag?.let { LocalCache.getOrCreateNote(it) } // keeps what came in. + ?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } // if it has loaded events with zero replies in the reply list + ?: originalNote?.replyTo?.firstOrNull() // old rules, first item is root. + ?: originalNote + } + + if (root != null) { val replyToSet = if (forkedFromNote != null) { (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } @@ -778,29 +972,37 @@ open class NewPostViewModel : ViewModel() { tagger.eTags } - account?.sendTorrentComment( - message = tagger.message, - replyTo = replyToSet, - mentions = tagger.pTags, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - replyingTo = replyId, - root = rootId, - directMentions = tagger.directMentions, - forkedFrom = forkedFromNote?.event as? Event, - relayList = relayList, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) + val sortedAndMarked = + eTags?.map { it.toETag() }?.positionalMarkedTags( + root = root.toETag(), + replyingTo = replyingTo?.toETag(), + forkedFrom = forkedFromNote?.toETag(), + ) + + val template = + TorrentCommentEvent.build(tagger.message) { + sortedAndMarked?.let { eTags(sortedAndMarked) } + + pTags(tagger.directMentionsUsers.map { it.toPTag() }) + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + + val broadcast = tagger.directMentionsNotes + (replyToSet ?: emptySet()) + + account?.sendTorrentComment(localDraft, template, broadcast, relayList) } } else if (originalNote?.event is TorrentEvent) { - val originalNoteEvent = originalNote?.event as TorrentEvent - // adds markers - val rootId = originalNoteEvent.id - val replyToSet = if (forkedFromNote != null) { (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } @@ -808,76 +1010,106 @@ open class NewPostViewModel : ViewModel() { tagger.eTags } - account?.sendTorrentComment( - message = tagger.message, - replyTo = replyToSet, - mentions = tagger.pTags, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - replyingTo = null, - root = rootId, - directMentions = tagger.directMentions, - forkedFrom = forkedFromNote?.event as? Event, - relayList = relayList, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) + val sortedAndMarked = + eTags?.map { it.toETag() }?.positionalMarkedTags( + root = originalNote?.toETag(), + replyingTo = null, + forkedFrom = forkedFromNote?.toETag(), + ) + + val template = + TorrentCommentEvent.build(tagger.message) { + sortedAndMarked?.let { eTags(sortedAndMarked) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + + val broadcast = tagger.directMentionsNotes + (replyToSet ?: emptySet()) + + account?.sendTorrentComment(localDraft, template, broadcast, relayList) } else { if (wantsPoll) { - account?.sendPoll( - message = tagger.message, - replyTo = tagger.eTags, - mentions = tagger.pTags, - pollOptions = pollOptions, - valueMaximum = valueMaximum, - valueMinimum = valueMinimum, - consensusThreshold = consensusThreshold, - closedAt = closedAt, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - relayList = relayList, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) + val options = pollOptions.map { PollOptionTag(it.key, it.value) } + + if (options.isEmpty()) return@withContext + + val quotes = findNostrUris(tagger.message) + + val template = + PollNoteEvent.build(tagger.message, options) { + valueMinimum?.let { minAmount(it) } + valueMaximum?.let { maxAmount(it) } + closedAt?.let { closedAt(it) } + consensusThreshold?.let { consensusThreshold(it / 100.0) } + + pTags(tagger.directMentionsUsers.map { it.toPTag() }) + quotes(quotes) + hashtags(findHashtags(tagger.message)) + + geoHash?.let { geohash(it) } + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + + account?.signAndSend(localDraft, template, relayList, quotes) } else if (wantsProduct) { - account?.sendClassifieds( - title = title.text, - price = Price(price.text, "SATS", null), - condition = condition, - message = tagger.message, - replyTo = tagger.eTags, - mentions = tagger.pTags, - location = locationText.text, - category = category.text, - directMentions = tagger.directMentions, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - relayList = relayList, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) + val images = + urls.mapNotNull { + val removedParamsFromUrl = + if (it.contains("?")) { + it.split("?")[0].lowercase() + } else if (it.contains("#")) { + it.split("#")[0].lowercase() + } else { + it + } + + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + it + } else { + null + } + } + + val quotes = findNostrUris(tagger.message) + + val template = + ClassifiedsEvent.build( + title.text, + PriceTag(price.text, "SATS", null), + tagger.message, + locationText.text.ifBlank { null }, + condition, + images, + ) { + hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message)) + quotes(quotes) + + geoHash?.let { geohash(it) } + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + + account?.signAndSend(localDraft, template, relayList, quotes) } else { - // adds markers - val rootId = - (originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root - ?: originalNote - ?.replyTo - ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } - ?.idHex // if it has loaded events with zero replies in the reply list - ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. - ?: originalNote?.idHex - - val replyId = originalNote?.idHex - val replyToSet = if (forkedFromNote != null) { (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } @@ -885,24 +1117,30 @@ open class NewPostViewModel : ViewModel() { tagger.eTags } - account?.sendPost( - message = tagger.message, - replyTo = replyToSet, - mentions = tagger.pTags, - tags = null, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - replyingTo = replyId, - root = rootId, - directMentions = tagger.directMentions, - forkedFrom = forkedFromNote?.event as? Event, - relayList = relayList, - geohash = geoHash, - imetas = usedAttachments, - emojis = emojis, - draftTag = localDraft, - ) + val template = + TextNoteEvent.build( + note = tagger.message, + replyingTo = originalNote?.toEventHint(), + forkingFrom = forkedFromNote?.toEventHint(), + ) { + tagger.pTags?.let { notify(it.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + + val broadcast = tagger.directMentionsNotes + (replyToSet ?: emptySet()) + + account?.signAndSend(localDraft, template, relayList, broadcast) } } } @@ -910,16 +1148,16 @@ open class NewPostViewModel : ViewModel() { fun findEmoji( message: String, myEmojiSet: List?, - ): List { + ): List { if (myEmojiSet == null) return emptyList() return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrl(it.code, it.url.url) } + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.url.url) } } } fun upload( alt: String?, - sensitiveContent: Boolean, + contentWarningReason: String?, mediaQuality: Int, isPrivate: Boolean = false, server: ServerName, @@ -937,7 +1175,7 @@ open class NewPostViewModel : ViewModel() { myMultiOrchestrator.upload( viewModelScope, alt, - sensitiveContent, + contentWarningReason, MediaCompressor.intToCompressorQuality(mediaQuality), server, myAccount, @@ -947,7 +1185,7 @@ open class NewPostViewModel : ViewModel() { if (results.allGood) { results.successful.forEach { if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 -> + account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, contentWarningReason) { nip95 -> nip95attachments = nip95attachments + nip95 val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } @@ -971,9 +1209,9 @@ open class NewPostViewModel : ViewModel() { ?.let { blurhash(it.blurhash) } it.result.magnet?.let { magnet(it) } it.result.uploadedHash?.let { originalHash(it) } + alt?.let { alt(it) } - // TODO: Support Reasons on images - if (sensitiveContent) sensitiveContent("") + contentWarningReason?.let { sensitiveContent(contentWarningReason) } }.build() iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta @@ -1021,7 +1259,7 @@ open class NewPostViewModel : ViewModel() { zapRaiserAmount = null wantsProduct = false - condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW + condition = ConditionTag.CONDITION.USED_LIKE_NEW locationText = TextFieldValue("") title = TextFieldValue("") category = TextFieldValue("") @@ -1333,47 +1571,19 @@ open class NewPostViewModel : ViewModel() { } fun updateMinZapAmountForPoll(textMin: String) { - if (textMin.isNotEmpty()) { - try { - val int = textMin.toInt() - if (int < 1) { - valueMinimum = null - } else { - valueMinimum = int - } - } catch (e: Exception) { - if (e is CancellationException) throw e - } - } else { - valueMinimum = null - } - + valueMinimum = textMin.toLongOrNull()?.takeIf { it > 0 } checkMinMax() saveDraft() } fun updateMaxZapAmountForPoll(textMax: String) { - if (textMax.isNotEmpty()) { - try { - val int = textMax.toInt() - if (int < 1) { - valueMaximum = null - } else { - valueMaximum = int - } - } catch (e: Exception) { - if (e is CancellationException) throw e - } - } else { - valueMaximum = null - } - + valueMaximum = textMax.toLongOrNull()?.takeIf { it > 0 } checkMinMax() saveDraft() } fun checkMinMax() { - if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) { + if ((valueMinimum ?: 0) > (valueMaximum ?: Long.MAX_VALUE)) { isValidvalueMinimum.value = false isValidvalueMaximum.value = false } else { @@ -1456,7 +1666,7 @@ open class NewPostViewModel : ViewModel() { saveDraft() } - fun updateCondition(newCondition: ClassifiedsEvent.CONDITION) { + fun updateCondition(newCondition: ConditionTag.CONDITION) { condition = newCondition saveDraft() } @@ -1471,20 +1681,3 @@ open class NewPostViewModel : ViewModel() { saveDraft() } } - -enum class GeohashPrecision( - val digits: Int, -) { - KM_5000_X_5000(1), // 5,000km × 5,000km - KM_1250_X_625(2), // 1,250km × 625km - KM_156_X_156(3), // 156km × 156km - KM_39_X_19(4), // 39.1km × 19.5km - KM_5_X_5(5), // 4.89km × 4.89km - M_1000_X_600(6), // 1.22km × 0.61km - M_153_X_153(7), // 153m × 153m - M_38_X_19(8), // 38.2m × 19.1m - M_5_X_5(9), // 4.77m × 4.77m - MM_1000_X_1000(10), // 1.19m × 0.596m - MM_149_X_149(11), // 149mm × 149mm - MM_37_X_18(12), // 37.2mm × 18.6mm -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt index ff48090e94..26ee67343b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt @@ -142,7 +142,7 @@ fun NewUserMetadataScreen( modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), ) { OutlinedTextField( - label = { Text(text = stringRes(R.string.display_name)) }, + label = { Text(text = stringRes(R.string.profile_name)) }, modifier = Modifier.fillMaxWidth(), value = postViewModel.displayName.value, onValueChange = { postViewModel.displayName.value = it }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index fc7d0e468e..9b6bb948e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextDecoration import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import kotlin.coroutines.cancellation.CancellationException diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt index 9574f0c5c0..d22297eed9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt @@ -41,4 +41,5 @@ val DEFAULT_MEDIA_SERVERS: List = ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom), ServerName("NostrCheck.me (Blossom)", "https://cdn.nostrcheck.me", ServerType.Blossom), ServerName("Nostr.Download", "https://nostr.download", ServerType.Blossom), + ServerName("NostrMedia (Paid)", "https://nostrmedia.com", ServerType.Blossom), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddDMRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddDMRelayListDialog.kt index 9ab6e2b1e2..e1f4806624 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddDMRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddDMRelayListDialog.kt @@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.quartz.nip01Core.relays.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.RelayStat @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddSearchRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddSearchRelayListDialog.kt index 0263895f65..feff3bacc2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddSearchRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddSearchRelayListDialog.kt @@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.quartz.nip01Core.relays.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.RelayStat @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt index da22b4b768..24549b4165 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt @@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.quartz.nip01Core.relays.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.RelayStat @Composable fun AllRelayListView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfo.kt index 11fcdbde40..0e82326b76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfo.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.actions.relays import androidx.compose.runtime.Immutable import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.relays.RelayStat +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.RelayStat @Immutable data class BasicRelaySetupInfo( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListView.kt index 85ddb78cfe..0f850e99f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListView.kt @@ -94,7 +94,7 @@ import com.vitorpamplona.ammolite.relays.Constants import com.vitorpamplona.ammolite.relays.Constants.activeTypesGlobalChats import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.nip01Core.relays.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.RelayStat import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalRow.kt index 46f940903a..c0e5233bbc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalRow.kt @@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.quartz.nip01Core.relays.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.RelayStat @OptIn(ExperimentalLayoutApi::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt index fcfc24366c..a6aec404d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt @@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayUrlEditField.kt index 39b17364da..3b33cba4cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayUrlEditField.kt @@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.relays.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.RelayStat import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index 47f87f4bb2..d50594d3c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -44,7 +44,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -69,7 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AmethystTheme -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.SmallishBorder @@ -151,11 +150,7 @@ fun CashuPreviewNew( val clipboardManager = LocalClipboardManager.current Card( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) - .clip(shape = QuoteBorder), + modifier = CashuCardBorders, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, 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 42ef9d7bcf..7ebb6981d9 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 @@ -63,11 +63,11 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed @@ -77,7 +77,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNIP19 -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt new file mode 100644 index 0000000000..beae54a704 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun AnimatedBorderTextCornerRadius( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + fontSize: TextUnit = 12.sp, +) { + val infiniteTransition = rememberInfiniteTransition() + val animatedFloatRestart = + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 100f, + animationSpec = + infiniteRepeatable( + animation = tween(5000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + + Text( + text = text, + fontSize = fontSize, + modifier = + modifier + .drawBehind { + val brush = + Brush.sweepGradient( + colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow), + ) + + drawRoundRect( + brush = brush, + style = + Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), animatedFloatRestart.value), + ), + cornerRadius = + androidx.compose.ui.geometry + .CornerRadius(6.dp.toPx()), + ) + }.padding(3.dp), + color = color, + textAlign = textAlign, + ) +} + +// Example usage in a composable function: +@Composable +@Preview +fun ExampleAnimatedBorder() { + Column { + AnimatedBorderTextCornerRadius(text = "Rounded Corners", Modifier) + } +} 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 5d167d9100..8772d93b22 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -62,6 +63,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import com.vitorpamplona.amethyst.commons.compose.produceCachedState +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.richtext.Base64Segment import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment @@ -77,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment +import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment import com.vitorpamplona.amethyst.model.HashtagIcon @@ -91,13 +94,16 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch fun isMarkdown(content: String): Boolean = content.startsWith("> ") || @@ -375,6 +381,7 @@ private fun RenderWordWithoutPreview( is WithdrawSegment -> Text(word.segmentText) is CashuSegment -> Text(word.segmentText) is EmailSegment -> ClickableEmail(word.segmentText) + is SecretEmoji -> Text(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) is HashTagSegment -> HashTag(word, nav) @@ -403,6 +410,7 @@ private fun RenderWordWithPreview( is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) + is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) is HashTagSegment -> HashTag(word, nav) @@ -510,6 +518,101 @@ fun DisplayFullNote( } } +@Composable +fun DisplaySecretEmoji( + segment: SecretEmoji, + state: RichTextViewerState, + callbackUri: String?, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (canPreview && quotesLeft > 0) { + var secretContent by remember { + mutableStateOf(null) + } + + var showPopup by remember { + mutableStateOf(false) + } + + LaunchedEffect(segment) { + launch(Dispatchers.Default) { + secretContent = + CachedRichTextParser.parseText( + EmojiCoder.decode(segment.segmentText), + state.tags, + ) + } + } + + val localSecretContent = secretContent + + AnimatedBorderTextCornerRadius( + segment.segmentText, + Modifier.clickable { + showPopup = !showPopup + }, + ) + + if (localSecretContent != null && showPopup) { + CoreSecretMessage(localSecretContent, callbackUri, quotesLeft, backgroundColor, accountViewModel, nav) + } + } else { + Text(segment.segmentText) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CoreSecretMessage( + localSecretContent: RichTextViewerState, + callbackUri: String?, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (localSecretContent.paragraphs.size == 1) { + localSecretContent.paragraphs[0].words.forEach { word -> + RenderWordWithPreview( + word, + localSecretContent, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } else if (localSecretContent.paragraphs.size > 1) { + val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + + Column(CashuCardBorders) { + localSecretContent.paragraphs.forEach { paragraph -> + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + paragraph.words.forEach { word -> + RenderWordWithPreview( + word, + localSecretContent, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } + } + } + } +} + @Composable fun HashTag( segment: HashTagSegment, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SecretEmojiRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SecretEmojiRequest.kt new file mode 100644 index 0000000000..4a768d4f3c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SecretEmojiRequest.kt @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.commons.hashtags.Lightning +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun SecretEmojiRequest(onSuccess: (String) -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Icon( + imageVector = CustomHashTagIcons.Lightning, + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) + + Text( + text = stringRes(R.string.secret_emoji_maker), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } + + HorizontalDivider(thickness = DividerThickness) + + var secretMessage by remember { mutableStateOf("") } + var publicPrefix by remember { mutableStateOf("") } + + OutlinedTextField( + label = { Text(text = stringRes(R.string.secret_note_to_receiver)) }, + modifier = Modifier.fillMaxWidth(), + value = secretMessage, + onValueChange = { secretMessage = it }, + placeholder = { + Text( + text = stringRes(R.string.secret_note_to_receiver_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + singleLine = true, + ) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.secret_visible_text)) }, + modifier = Modifier.fillMaxWidth(), + value = publicPrefix, + onValueChange = { publicPrefix = it }, + placeholder = { + Text( + text = stringRes(R.string.secret_visible_text_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { + onSuccess(EmojiCoder.encode(publicPrefix, secretMessage)) + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = stringRes(R.string.secret_add_to_text), + color = Color.White, + fontSize = 20.sp, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt new file mode 100644 index 0000000000..f11dce9658 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt @@ -0,0 +1,160 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +// COPIED FROM TEXT FIELD +// The only change is the contentPadding below + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThinPaddingTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource? = null, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors(), + // new fields + contentPadding: PaddingValues = + if (label == null) { + TextFieldDefaults.contentPaddingWithoutLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ) + } else { + TextFieldDefaults.contentPaddingWithLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ) + }, +) { + @Suppress("NAME_SHADOWING") + val interactionSource = interactionSource ?: remember { MutableInteractionSource() } + + // If color is not provided via the text style, use content color as a default + val textColor = + textStyle.color.takeOrElse { + val focused by interactionSource.collectIsFocusedAsState() + + // this has changed, but only because of private access on the original + when { + !enabled -> MaterialTheme.colorScheme.placeholderText + isError -> MaterialTheme.colorScheme.onSurface + focused -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface + } + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { + BasicTextField( + value = value, + modifier = + modifier + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = 36.dp, // this has changed + ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), // this has changed + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = + @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value.text, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = contentPadding, // this has changed + ) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 423ea160f4..0fbc34067c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -119,8 +119,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension -import kotlinx.collections.immutable.ImmutableList +import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -208,10 +208,10 @@ fun VideoView( roundedCorner: Boolean, gallery: Boolean = false, contentScale: ContentScale, - waveform: ImmutableList? = null, + waveform: WaveformTag? = null, artworkUri: String? = null, authorName: String? = null, - dimensions: Dimension? = null, + dimensions: DimensionTag? = null, blurhash: String? = null, nostrUriCallback: String? = null, onDialog: ((Boolean) -> Unit)? = null, @@ -239,10 +239,10 @@ fun VideoView( thumb: VideoThumb? = null, borderModifier: Modifier, contentScale: ContentScale, - waveform: ImmutableList? = null, + waveform: WaveformTag? = null, artworkUri: String? = null, authorName: String? = null, - dimensions: Dimension? = null, + dimensions: DimensionTag? = null, blurhash: String? = null, nostrUriCallback: String? = null, onDialog: ((Boolean) -> Unit)? = null, @@ -355,7 +355,7 @@ fun VideoViewInner( showControls: Boolean = true, contentScale: ContentScale, borderModifier: Modifier, - waveform: ImmutableList? = null, + waveform: WaveformTag? = null, artworkUri: String? = null, authorName: String? = null, nostrUriCallback: String? = null, @@ -732,7 +732,7 @@ private fun RenderVideoPlayer( showControls: Boolean = true, contentScale: ContentScale, nostrUriCallback: String?, - waveform: ImmutableList? = null, + waveform: WaveformTag? = null, keepPlaying: MutableState, automaticallyStartPlayback: State, activeOnScreen: MutableState, @@ -863,7 +863,7 @@ private fun pollCurrentDuration(controller: MediaController) = @Composable fun Waveform( - waveform: ImmutableList, + waveform: WaveformTag, controller: MediaController, modifier: Modifier, ) { @@ -897,13 +897,13 @@ fun Waveform( @Composable fun DrawWaveform( - waveform: ImmutableList, + waveform: WaveformTag, waveformProgress: MutableFloatState, modifier: Modifier, ) { AudioWaveformReadOnly( modifier = modifier.padding(start = 10.dp, end = 10.dp), - amplitudes = waveform, + amplitudes = waveform.wave, progress = waveformProgress.floatValue, progressBrush = Brush.infiniteLinearGradient( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 17d98c244c..c99dc3cb6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -308,6 +308,7 @@ private fun saveMediaToGallery( MediaSaverToDisk.downloadAndSave( content.url, + mimeType = content.mimeType, forceProxy = useTor, localContext, onSuccess = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 5f3c32e11f..378a2dddc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.components import android.util.Log import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable @@ -93,11 +94,11 @@ import com.vitorpamplona.amethyst.ui.theme.Size30dp import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -264,7 +265,9 @@ fun LocalImageView( ) } } else { - DisplayUrlWithLoadingSymbol(content) + WaitAndDisplay { + DisplayUrlWithLoadingSymbol(content) + } } } is AsyncImagePainter.State.Error -> { @@ -370,7 +373,9 @@ fun UrlImageView( ) } } else { - DisplayUrlWithLoadingSymbol(content) + WaitAndDisplay { + DisplayUrlWithLoadingSymbol(content) + } } } is AsyncImagePainter.State.Error -> { @@ -514,12 +519,33 @@ fun ShowHash(content: MediaUrlContent) { verifiedHash?.let { HashVerificationSymbol(it) } } -fun aspectRatio(dim: Dimension?): Float? { +fun aspectRatio(dim: DimensionTag?): Float? { if (dim == null) return null return dim.width.toFloat() / dim.height.toFloat() } +@Composable +fun WaitAndDisplay( + content: + @Composable() + (AnimatedVisibilityScope.() -> Unit), +) { + val visible = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + delay(200) + visible.value = true + } + + AnimatedVisibility( + visible = visible.value, + enter = fadeIn(), + exit = fadeOut(), + content = content, + ) +} + @Composable fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) { val uri = LocalUriHandler.current @@ -641,7 +667,7 @@ fun ShareImageAction( videoUri: String?, postNostrUri: String?, blurhash: String?, - dim: Dimension?, + dim: DimensionTag?, hash: String?, mimeType: String?, onDismiss: () -> Unit, @@ -679,7 +705,7 @@ fun ShareImageAction( if (videoUri != null) { val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent if (n19 != null) { - accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay[0], blurhash, dim, hash, mimeType) // TODO Whole list or first? + accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) // TODO Whole list or first? accountViewModel.toast(R.string.media_added, R.string.media_added_to_profile_gallery) } } @@ -695,7 +721,7 @@ private suspend fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null Amethyst.instance.coilCache.openSnapshot(content.url)?.use { snapshot -> - val hash = CryptoUtils.sha256(snapshot.data.toFile().readBytes()).toHexKey() + val hash = sha256(snapshot.data.toFile().readBytes()).toHexKey() Log.d("Image Hash Verification", "$hash == ${content.hash}") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index e786fed2ca..2f31da4e89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -48,8 +48,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink import com.vitorpamplona.amethyst.ui.theme.Font17SP import com.vitorpamplona.amethyst.ui.theme.Size17Modifier -import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList -import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent @@ -57,11 +55,12 @@ 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.nip92IMeta.IMetaTag import kotlinx.coroutines.runBlocking class MarkdownMediaRenderer( val startOfText: String, - val tags: ImmutableListOfLists?, + val imetaByUrl: Map, val canPreview: Boolean, val quotesLeft: Int, val backgroundColor: MutableState, @@ -94,7 +93,7 @@ class MarkdownMediaRenderer( val content = parser.createMediaContent( fullUrl = uri, - eventTags = tags ?: EmptyTagList, + eventTags = imetaByUrl, description = title?.ifEmpty { null } ?: startOfText, ) ?: MediaUrlImage(url = uri, description = title?.ifEmpty { null } ?: startOfText) @@ -116,7 +115,7 @@ class MarkdownMediaRenderer( uri: String, richTextStringBuilder: RichTextString.Builder, ) { - val content = parser.createMediaContent(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri) + val content = parser.createMediaContent(uri, imetaByUrl, startOfText, callbackUri) if (canPreview) { if (content != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index d7ce17ee50..6c51042162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -49,10 +49,11 @@ import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.markdownStyle import com.vitorpamplona.amethyst.ui.uriToRoute -import com.vitorpamplona.quartz.nip01Core.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -93,7 +94,7 @@ fun RenderContentAsMarkdown( remember(content) { MarkdownMediaRenderer( startOfText = content.take(100), - tags = tags, + imetaByUrl = tags?.lists?.imetasByUrl() ?: emptyMap(), canPreview = canPreview, quotesLeft = quotesLeft, backgroundColor = backgroundColor, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index 6293aeb4e8..b1aa0119e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey class ChatroomFeedFilter( val withUser: ChatroomKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index 8a7073c22f..58c139aae9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -25,9 +25,9 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.relays.updated import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent class ChatroomListKnownFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index 1d55278a06..faee792da6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.relays.updated -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable class ChatroomListNewFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt index 39d42849dc..f8108d057b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt @@ -24,9 +24,9 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent class CommunityFeedFilter( val note: AddressableNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt index 0a26b7b320..f9d25420b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt @@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.IsInPublicChatChannel +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 @@ -85,7 +85,7 @@ open class DiscoverChatFeedFilter( null } } else if (noteEvent is IsInPublicChatChannel) { - val channel = noteEvent.channel()?.let { LocalCache.checkGetOrCreateNote(it) } + val channel = noteEvent.channelId()?.let { LocalCache.checkGetOrCreateNote(it) } val channelEvent = channel?.event if (channel != null && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt index 6693745b3c..6737d99be9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt @@ -23,12 +23,11 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip19Bech32.parseAtagUnckecked +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent open class DiscoverCommunityFeedFilter( val account: Account, @@ -54,7 +53,7 @@ open class DiscoverCommunityFeedFilter( val notes = LocalCache.addressables.mapNotNullIntoSet { key, note -> val noteEvent = note.event - if (noteEvent == null && shouldInclude(ATag.parseAtagUnckecked(key), filterParams)) { + if (noteEvent == null && shouldInclude(Address.parse(key), filterParams)) { // send unloaded communities to the screen note } else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) { @@ -86,7 +85,7 @@ open class DiscoverCommunityFeedFilter( if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) { listOf(note) } else if (noteEvent is CommunityPostApprovalEvent) { - noteEvent.communities().mapNotNull { + noteEvent.communityAddresses().mapNotNull { val definitionNote = LocalCache.getOrCreateAddressableNote(it) val definitionEvent = definitionNote.event @@ -106,7 +105,7 @@ open class DiscoverCommunityFeedFilter( } private fun shouldInclude( - aTag: ATag?, + aTag: Address?, params: FilterByListParams, ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt index c31137858e..85c56cba2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt @@ -26,10 +26,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent.Companion.STATUS_ENDED -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent.Companion.STATUS_LIVE -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent.Companion.STATUS_PLANNED +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag open class DiscoverLiveFeedFilter( val account: Account, @@ -93,9 +91,9 @@ open class DiscoverLiveFeedFilter( fun convertStatusToOrder(status: String?): Int = when (status) { - STATUS_LIVE -> 2 - STATUS_PLANNED -> 1 - STATUS_ENDED -> 0 + StatusTag.STATUS.LIVE.code -> 2 + StatusTag.STATUS.PLANNED.code -> 1 + StatusTag.STATUS.ENDED.code -> 0 else -> 0 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt index 19b13c8800..da10b3858c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverNIP89FeedFilter( @@ -76,7 +76,7 @@ open class DiscoverNIP89FeedFilter( val filterParams = buildFilterParams(account) return noteEvent.appMetaData()?.subscription != true && filterParams.match(noteEvent) && - noteEvent.includeKind("5300") && + noteEvent.includeKind(5300) && noteEvent.createdAt > TimeUtils.now() - lastAnnounced // && params.match(noteEvent) } 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 b93515027a..4f3d850b59 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 @@ -24,14 +24,14 @@ import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.utils.TimeUtils class FilterByListParams( @@ -68,10 +68,10 @@ class FilterByListParams( } } - fun isATagInList(aTag: ATag): Boolean { + fun isAuthorInFollows(address: Address): Boolean { if (followLists == null) return false - return aTag.pubKeyHex in followLists.authors + return address.pubKeyHex in followLists.authors } fun match( @@ -81,10 +81,10 @@ class FilterByListParams( (isHiddenList || isNotHidden(noteEvent.pubKey)) && isNotInTheFuture(noteEvent) - fun match(aTag: ATag?) = - aTag != null && - (isGlobal || isATagInList(aTag)) && - (isHiddenList || isNotHidden(aTag.pubKeyHex)) + fun match(address: Address?) = + address != null && + (isGlobal || isAuthorInFollows(address)) && + (isHiddenList || isNotHidden(address.pubKeyHex)) companion object { fun showHiddenKey( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt index 92ad7b3c74..6061f6b34d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt @@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent class GeoHashFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index 2626e695db..1881067b1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -23,15 +23,15 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent 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.nip28PublicChat.ChannelMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent class HashtagFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index f4678e0562..e057bc091e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent class HomeConversationsFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 87661230aa..f1c079d0b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 4a41b1e7fa..7809871d57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -23,23 +23,25 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.experimental.forks.forkFromVersion +import com.vitorpamplona.quartz.experimental.forks.isForkFromAddressWithPubkey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip34Git.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.GitPatchEvent +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.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent @@ -138,13 +140,13 @@ class NotificationFeedFilter( return true } - if (event is BaseTextNoteEvent) { + if (event is BaseThreadedEvent) { if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) return true val isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } val isAuthorDirectlyCited = event.citedUsers().contains(authorHex) val isAuthorOfAFork = - event.isForkFromAddressWithPubkey(authorHex) || (event.forkFromVersion()?.let { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } == true) + event.isForkFromAddressWithPubkey(authorHex) || (event.forkFromVersion()?.let { LocalCache.getNoteIfExists(it.eventId)?.author?.pubkeyHex == authorHex } == true) return isAuthoredPostCited || isAuthorDirectlyCited || isAuthorOfAFork } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt index 9864579500..af5b729fc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.nip89AppHandlers.AppRecommendationEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent class UserProfileAppRecommendationsFeedFilter( val user: User, @@ -47,7 +47,7 @@ class UserProfileAppRecommendationsFeedFilter( val noteEvent = it.event if (noteEvent is AppRecommendationEvent) { if (noteEvent.pubKey == user.pubkeyHex) { - return noteEvent.recommendations().map { LocalCache.getOrCreateAddressableNote(it) } + return noteEvent.recommendations().map { LocalCache.getOrCreateAddressableNote(it.address) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt index 18d10f1ce5..66ca7fd2fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt @@ -27,9 +27,9 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent class UserProfileConversationsFeedFilter( val user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileMutualFeedFilter.kt new file mode 100644 index 0000000000..bfa01ace9a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileMutualFeedFilter.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser +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.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +class UserProfileMutualFeedFilter( + val user: User, + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex + + override fun feed(): List { + val notes = + LocalCache.notes.filterIntoSet { _, it -> + it !is AddressableNote && it.event !is AddressableEvent && acceptableEvent(it) + } + + val longFormNotes = + LocalCache.addressables.filterIntoSet { _, it -> + acceptableEvent(it) + } + + return sort(notes + longFormNotes) + } + + override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) + + private fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { acceptableEvent(it) } + + fun acceptableEvent(it: Note): Boolean = + it.author == account.userProfile() && + ( + it.event is TextNoteEvent || + it.event is ClassifiedsEvent || + it.event is RepostEvent || + it.event is GenericRepostEvent || + it.event is LongTextNoteEvent || + it.event is WikiNoteEvent || + it.event is PollNoteEvent || + it.event is HighlightEvent || + it.event is InteractiveStoryPrologueEvent || + it.event is AudioTrackEvent || + it.event is AudioHeaderEvent || + it.event is TorrentEvent + ) && + it.event?.isTaggedUser(user.pubkeyHex) == true + + override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) + + override fun limit() = 200 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 19c6e1e62d..6e56760647 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt index ac1c7c007f..ebb0d75670 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ZapReqResponse +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent class UserProfileZapsFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index 1c73d589a1..565b2ba64d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index dccb3de4a4..77e7e68c7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.NewItemsBubble +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.NewItemsBubble import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer 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 2c95d5f817..6c84bdabca 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 @@ -60,10 +60,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomListScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomScreenByAuthor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.MessagesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen @@ -95,6 +95,16 @@ fun NavBackStackEntry.message(): String? = URLDecoder.decode(it, "utf-8") } +fun NavBackStackEntry.replyId(): String? = + arguments?.getString("replyId")?.let { + URLDecoder.decode(it, "utf-8") + } + +fun NavBackStackEntry.draftId(): String? = + arguments?.getString("draftId")?.let { + URLDecoder.decode(it, "utf-8") + } + @Composable fun AppNavigation( accountViewModel: AccountViewModel, @@ -111,7 +121,7 @@ fun AppNavigation( exitTransition = { fadeOut(animationSpec = tween(200)) }, ) { composable(Route.Home.route) { HomeScreen(accountViewModel, nav) } - composable(Route.Message.route) { ChatroomListScreen(accountViewModel, nav) } + composable(Route.Message.route) { MessagesScreen(accountViewModel, nav) } composable(Route.Video.route) { VideoScreen(accountViewModel, nav) } composable(Route.Discover.route) { DiscoverScreen(accountViewModel, nav) } composable(Route.Notification.route) { NotificationScreen(sharedPreferencesViewModel, accountViewModel, nav) } @@ -228,6 +238,8 @@ fun AppNavigation( ChatroomScreen( roomId = it.id(), draftMessage = it.message(), + replyToNote = it.replyId(), + editFromDraft = it.draftId(), accountViewModel = accountViewModel, nav = nav, ) @@ -241,7 +253,7 @@ fun AppNavigation( popEnterTransition = { scaleIn }, popExitTransition = { slideOutHorizontallyToEnd }, ) { - ChatroomScreenByAuthor(it.id(), null, accountViewModel, nav) + ChatroomByAuthorScreen(it.id(), null, accountViewModel, nav) } composable( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 3d64dbe718..a1ef2b0306 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -112,8 +112,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog import com.vitorpamplona.ammolite.relays.RelayPoolStatus -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists @Composable @@ -278,7 +278,7 @@ private fun EditStatusBoxes( @Composable fun StatusEditBar( savedStatus: String? = null, - tag: ATag? = null, + address: Address? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -311,10 +311,10 @@ fun StatusEditBar( keyboardActions = KeyboardActions( onSend = { - if (tag == null) { + if (address == null) { accountViewModel.createStatus(currentStatus.value) } else { - accountViewModel.updateStatus(tag, currentStatus.value) + accountViewModel.updateStatus(address, currentStatus.value) } focusManager.clearFocus(true) @@ -324,17 +324,17 @@ fun StatusEditBar( trailingIcon = { if (hasChanged.value) { SendButton { - if (tag == null) { + if (address == null) { accountViewModel.createStatus(currentStatus.value) } else { - accountViewModel.updateStatus(tag, currentStatus.value) + accountViewModel.updateStatus(address, currentStatus.value) } focusManager.clearFocus(true) } } else { - if (tag != null) { + if (address != null) { UserStatusDeleteButton { - accountViewModel.deleteStatus(tag) + accountViewModel.deleteStatus(address) focusManager.clearFocus(true) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt index aa04022e2c..a5daf3ab64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt @@ -54,6 +54,8 @@ interface INav { fun nav(route: String) + fun nav(computeRoute: suspend () -> String) + fun newStack(route: String) fun popBack() @@ -91,6 +93,15 @@ class Nav( } } + override fun nav(computeRoute: suspend () -> String) { + scope.launch { + val route = computeRoute() + if (getRouteWithArguments(controller) != route) { + controller.navigate(route) + } + } + } + override fun newStack(route: String) { scope.launch { controller.navigate(route) { @@ -135,6 +146,9 @@ object EmptyNav : INav { override fun nav(route: String) { } + override fun nav(computeRoute: suspend () -> String) { + } + override fun newStack(route: String) { } @@ -161,6 +175,11 @@ class ObservableNavigate( nav.nav(route) } + override fun nav(computeRoute: suspend () -> String) { + onNavigate() + nav.nav(computeRoute) + } + override fun newStack(route: String) { onNavigate() nav.newStack(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt index e64e4c7013..a30c8a3bca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt @@ -21,22 +21,23 @@ package com.vitorpamplona.amethyst.ui.navigation import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.LocalCache.users import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.IsInPublicChatChannel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent -import kotlinx.collections.immutable.persistentSetOf +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import java.net.URLEncoder fun routeFor( @@ -56,11 +57,11 @@ fun routeFor( val innerEvent = noteEvent.preCachedDraft(loggedIn.pubkeyHex) if (innerEvent is IsInPublicChatChannel) { - innerEvent.channel()?.let { + innerEvent.channelId()?.let { return "Channel/$it" } } else if (innerEvent is LiveActivitiesEvent) { - innerEvent.address().toTag().let { + innerEvent.aTag().toTag().let { return "Channel/${URLEncoder.encode(it, "utf-8")}" } } else if (innerEvent is LiveActivitiesChatMessageEvent) { @@ -72,20 +73,20 @@ fun routeFor( loggedIn.createChatroom(room) return "Room/${room.hashCode()}" } else if (innerEvent is AddressableEvent) { - return "Note/${URLEncoder.encode(noteEvent.address().toTag(), "utf-8")}" + return "Note/${URLEncoder.encode(noteEvent.aTag().toTag(), "utf-8")}" } else { return "Note/${URLEncoder.encode(noteEvent.id, "utf-8")}" } } else if (noteEvent is AppDefinitionEvent) { return "ContentDiscovery/${noteEvent.id}" } else if (noteEvent is IsInPublicChatChannel) { - noteEvent.channel()?.let { + noteEvent.channelId()?.let { return "Channel/$it" } } else if (noteEvent is ChannelCreateEvent) { return "Channel/${noteEvent.id}" } else if (noteEvent is LiveActivitiesEvent) { - noteEvent.address().toTag().let { + noteEvent.aTag().toTag().let { return "Channel/${URLEncoder.encode(it, "utf-8")}" } } else if (noteEvent is LiveActivitiesChatMessageEvent) { @@ -97,9 +98,9 @@ fun routeFor( loggedIn.createChatroom(room) return "Room/${room.hashCode()}" } else if (noteEvent is CommunityDefinitionEvent) { - return "Community/${URLEncoder.encode(noteEvent.address().toTag(), "utf-8")}" + return "Community/${URLEncoder.encode(noteEvent.aTag().toTag(), "utf-8")}" } else if (noteEvent is AddressableEvent) { - return "Note/${URLEncoder.encode(noteEvent.address().toTag(), "utf-8")}" + return "Note/${URLEncoder.encode(noteEvent.aTag().toTag(), "utf-8")}" } else { return "Note/${URLEncoder.encode(noteEvent.id, "utf-8")}" } @@ -110,23 +111,71 @@ fun routeFor( fun routeToMessage( user: HexKey, draftMessage: String?, + replyId: HexKey? = null, + quoteId: HexKey? = null, + accountViewModel: AccountViewModel, +): String = + routeToMessage( + setOf(user), + draftMessage, + replyId, + quoteId, + accountViewModel, + ) + +fun routeToMessage( + users: Set, + draftMessage: String?, + replyId: HexKey? = null, + quoteId: HexKey? = null, + accountViewModel: AccountViewModel, +) = routeToMessage( + ChatroomKey(users), + draftMessage, + replyId, + quoteId, + accountViewModel, +) + +fun routeToMessage( + room: ChatroomKey, + draftMessage: String?, + replyId: HexKey? = null, + quoteId: HexKey? = null, accountViewModel: AccountViewModel, ): String { - val withKey = ChatroomKey(persistentSetOf(user)) - accountViewModel.account.userProfile().createChatroom(withKey) - return if (draftMessage != null) { - val encodedMessage = URLEncoder.encode(draftMessage, "utf-8") - "Room/${withKey.hashCode()}?message=$encodedMessage" - } else { - "Room/${withKey.hashCode()}" + accountViewModel.account.userProfile().createChatroom(room) + + val params = + listOfNotNull( + draftMessage?.let { + "message=${URLEncoder.encode(it, "utf-8")}" + }, + replyId?.let { + "replyId=${URLEncoder.encode(it, "utf-8")}" + }, + quoteId?.let { + "quoteId=${URLEncoder.encode(it, "utf-8")}" + }, + ) + + return buildString { + append("Room/") + append(room.hashCode().toString()) + if (params.isNotEmpty()) { + append("?") + append(params.joinToString("&")) + } } } fun routeToMessage( user: User, draftMessage: String?, + replyId: HexKey? = null, + quoteId: HexKey? = null, accountViewModel: AccountViewModel, -): String = routeToMessage(user.pubkeyHex, draftMessage, accountViewModel) +): String = routeToMessage(user.pubkeyHex, draftMessage, replyId, quoteId, accountViewModel) fun routeFor(note: Channel): String = "Channel/${note.idHex}" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 4c3810eca4..56c1733b72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -176,7 +176,7 @@ sealed class Route( object Room : Route( - route = "Room/{id}?message={message}", + route = "Room/{id}?message={message}&replyId={replyId}&draftId={draftId}", icon = R.drawable.ic_moments, arguments = listOf( @@ -186,6 +186,16 @@ sealed class Route( nullable = true defaultValue = null }, + navArgument("replyId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("draftId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, ).toImmutableList(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 5074e8fd56..ebe9ac5457 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -78,15 +78,14 @@ import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.EndedFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.OfflineFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.EndedFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.OfflineFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ScheduledFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfPadding @@ -102,16 +101,15 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.nip05 -import com.vitorpamplona.quartz.experimental.audio.Participant -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent.Companion.STATUS_ENDED -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent.Companion.STATUS_LIVE -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent.Companion.STATUS_PLANNED -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.nip99Classifieds.Price +import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -295,7 +293,7 @@ private fun RenderNoteRow( data class ClassifiedsThumb( val image: String?, val title: String?, - val price: Price?, + val price: PriceTag?, ) @Composable @@ -339,7 +337,7 @@ fun RenderClassifiedsThumbPreview() { ClassifiedsThumb( image = null, title = "Like New", - price = Price("800000", "SATS", null), + price = PriceTag("800000", "SATS", null), ), note = Note("hex"), ) @@ -387,7 +385,7 @@ fun InnerRenderClassifiedsThumb( card.price?.let { val priceTag = remember(card) { - val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountAxis(it) } ?: it.amount + val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountInteger(it) } ?: it.amount if (it.frequency != null && it.currency != null) { "$newAmount ${it.currency}/${it.frequency}" @@ -416,7 +414,7 @@ data class LiveActivityCard( val media: String?, val subject: String?, val content: String?, - val participants: ImmutableList, + val participants: ImmutableList, val status: String?, val starts: Long?, ) @@ -485,7 +483,7 @@ fun RenderLiveActivityThumb( Box(Modifier.padding(10.dp)) { CrossfadeIfEnabled(targetState = card.status, label = "RenderLiveActivityThumb", accountViewModel = accountViewModel) { when (it) { - STATUS_LIVE -> { + StatusTag.STATUS.LIVE.code -> { val url = card.media if (url.isNullOrBlank()) { LiveFlag() @@ -499,10 +497,10 @@ fun RenderLiveActivityThumb( } } } - STATUS_ENDED -> { + StatusTag.STATUS.ENDED.code -> { EndedFlag() } - STATUS_PLANNED -> { + StatusTag.STATUS.PLANNED.code -> { ScheduledFlag(card.starts) } else -> { @@ -544,7 +542,7 @@ data class CommunityCard( val name: String, val description: String?, val cover: String?, - val moderators: ImmutableList, + val moderators: ImmutableList, ) @Immutable @@ -574,16 +572,16 @@ fun RenderCommunitiesThumb( CommunityCard( name = noteEvent?.dTag() ?: "", description = noteEvent?.description(), - cover = noteEvent?.image()?.ifBlank { null }, - moderators = noteEvent?.moderators()?.toImmutableList() ?: persistentListOf(), + cover = noteEvent?.image()?.imageUrl, + moderators = noteEvent?.moderatorKeys()?.toImmutableList() ?: persistentListOf(), ) }.distinctUntilChanged() .observeAsState( CommunityCard( name = noteEvent.dTag(), description = noteEvent.description(), - cover = noteEvent.image()?.ifBlank { null }, - moderators = noteEvent.moderators().toImmutableList(), + cover = noteEvent.image()?.imageUrl, + moderators = noteEvent.moderatorKeys().toImmutableList(), ), ) @@ -655,7 +653,7 @@ fun RenderCommunitiesThumb( @Composable fun LoadModerators( - moderators: ImmutableList, + moderators: ImmutableList, baseNote: Note, accountViewModel: AccountViewModel, content: @Composable (ImmutableList) -> Unit, @@ -670,8 +668,8 @@ fun LoadModerators( launch(Dispatchers.IO) { val hosts = moderators.mapNotNull { part -> - if (part.key != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(part.key) + if (part != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part) } else { null } @@ -706,7 +704,7 @@ fun LoadModerators( @Composable private fun LoadParticipants( - participants: ImmutableList, + participants: ImmutableList, baseNote: Note, accountViewModel: AccountViewModel, inner: @Composable (ImmutableList) -> Unit, @@ -721,8 +719,8 @@ private fun LoadParticipants( launch(Dispatchers.IO) { val hosts = participants.mapNotNull { part -> - if (part.key != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(part.key) + if (part.pubKey != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part.pubKey) } else { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 556b522d47..126117a98d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext @@ -39,7 +38,7 @@ import com.vitorpamplona.amethyst.service.CachedGeoLocations import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -73,15 +72,15 @@ fun LoadDecryptedContentOrNull( accountViewModel: AccountViewModel, inner: @Composable (String?) -> Unit, ) { - @Suppress("ProduceStateDoesNotAssignValue") - val decryptedContent by - produceState(initialValue = accountViewModel.cachedDecrypt(note), key1 = note.event?.id) { - accountViewModel.decrypt(note) { - if (value != it) { - value = it - } + var decryptedContent by remember(note.event?.id) { mutableStateOf(accountViewModel.cachedDecrypt(note)) } + + LaunchedEffect(note.event?.id) { + accountViewModel.decrypt(note) { + if (decryptedContent != it) { + decryptedContent = it } } + } inner(decryptedContent) } @@ -112,20 +111,20 @@ fun LoadAddressableNote( @Composable fun LoadAddressableNote( - aTag: ATag, + address: Address, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit, ) { var note by - remember(aTag) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag())) + remember(address) { + mutableStateOf(accountViewModel.getAddressableNoteIfExists(address.toValue())) } if (note == null) { - LaunchedEffect(key1 = aTag) { + LaunchedEffect(key1 = address) { val newNote = withContext(Dispatchers.IO) { - accountViewModel.getOrCreateAddressableNote(aTag) + accountViewModel.getOrCreateAddressableNote(address) } if (note != newNote) { note = newNote 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 4dff1bd048..04d59482be 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 @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.MessageSetCard +import com.vitorpamplona.amethyst.ui.theme.StdStartPadding import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @@ -53,7 +54,7 @@ fun MessageSetCompose( accountViewModel: AccountViewModel, nav: INav, ) { - val baseNote = remember { messageSetCard.note } + val baseNote = messageSetCard.note val popupExpanded = remember { mutableStateOf(false) } val enablePopup = remember { { popupExpanded.value = true } } @@ -90,15 +91,9 @@ fun MessageSetCompose( Column(columnModifier) { Row(Modifier.fillMaxWidth()) { - Box( - modifier = remember { Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp) }, - ) { - MessageIcon( - remember { Modifier.size(16.dp).align(Alignment.TopEnd) }, - ) - } + MessageIconBox() - Column(modifier = remember { Modifier.padding(start = 10.dp) }) { + Column(modifier = StdStartPadding) { NoteCompose( baseNote = baseNote, routeForLastRead = null, @@ -117,3 +112,10 @@ fun MessageSetCompose( } } } + +@Composable +fun MessageIconBox() { + Box(Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp)) { + MessageIcon(Modifier.size(16.dp).align(Alignment.TopEnd)) + } +} 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 aa60ee5bab..1f0a9252e1 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 @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -53,15 +54,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius +import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -92,6 +102,7 @@ import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.time.ExperimentalTime @@ -212,7 +223,18 @@ fun RenderLikeGallery( when (val shortReaction = reactionType) { "+" -> LikedIcon(modifier.size(Size19dp)) "-" -> Text(text = "\uD83D\uDC4E", modifier = modifier) - else -> Text(text = shortReaction, modifier = modifier) + else -> { + if (EmojiCoder.isCoded(shortReaction)) { + DisplaySecretEmojiAsReaction( + shortReaction, + modifier, + accountViewModel, + nav, + ) + } else { + Text(text = shortReaction, modifier = modifier) + } + } } } } @@ -222,6 +244,57 @@ fun RenderLikeGallery( } } +@Composable +fun DisplaySecretEmojiAsReaction( + reaction: String, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + var secretContent by remember { + mutableStateOf(null) + } + + var showPopup by remember { + mutableStateOf(false) + } + + LaunchedEffect(reaction) { + launch(Dispatchers.Default) { + secretContent = + CachedRichTextParser.parseText( + EmojiCoder.decode(reaction), + EmptyTagList, + ) + } + } + + val localSecretContent = secretContent + + AnimatedBorderTextCornerRadius( + reaction, + modifier.clickable { + showPopup = !showPopup + }, + ) + + if (localSecretContent != null && showPopup) { + val iconSizePx = with(LocalDensity.current) { -24.dp.toPx().toInt() } + + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, -iconSizePx), + onDismissRequest = { showPopup = false }, + properties = PopupProperties(focusable = true), + ) { + Surface(Modifier.padding(10.dp)) { + val color = remember { mutableStateOf(Color.Transparent) } + CoreSecretMessage(localSecretContent, null, 3, color, accountViewModel, nav) + } + } + } +} + @Composable fun DecryptAndRenderZapGallery( multiSetCard: MultiSetCard, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiUserMessageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiUserMessageDialog.kt index ab3e427723..789f052bcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiUserMessageDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiUserMessageDialog.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -74,7 +73,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -202,15 +200,14 @@ fun ErrorRow( horizontalArrangement = Arrangement.SpaceBetween, ) { errorState.user?.let { - val scope = rememberCoroutineScope() Column(Modifier.width(Size40dp), horizontalAlignment = Alignment.Start) { UserPicture(errorState.user, Size30dp, Modifier, accountViewModel, nav) Spacer(StdVertSpacer) IconButton( modifier = Size30Modifier, onClick = { - scope.launch(Dispatchers.IO) { - nav.nav(routeToMessage(it, errorState.error, accountViewModel)) + nav.nav { + routeToMessage(it, errorState.error, accountViewModel = accountViewModel) } }, ) { 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 f244233b1d..f0a4770156 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 @@ -72,8 +72,8 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.UserMetadata -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.addressables.firstTaggedAddress import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent @@ -276,7 +276,7 @@ fun DisplayStatusInner( content: String, type: String, url: String?, - nostrATag: ATag?, + nostrATag: Address?, nostrETag: ETag?, accountViewModel: AccountViewModel, nav: INav, 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 4d5e08d346..64f7b6fa9e 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 @@ -123,12 +123,11 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.RenderChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.RenderChannelHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.HalfEndPadding import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp @@ -149,36 +148,36 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.bounties.getReward +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.forks.isAFork import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip13Pow.pow import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEncryptedFileHeaderEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackEvent -import com.vitorpamplona.quartz.nip34Git.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.GitRepositoryEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +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 @@ -186,8 +185,8 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup @@ -198,10 +197,10 @@ import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent @@ -963,7 +962,7 @@ fun SecondUserInfoRow( verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight, ) { - if (noteEvent is BaseTextNoteEvent && noteEvent.isAFork()) { + if (noteEvent is BaseThreadedEvent && noteEvent.isAFork()) { ShowForkInformation(noteEvent, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav) } else { ObserveDisplayNip05Status(noteAuthor, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav) @@ -975,7 +974,7 @@ fun SecondUserInfoRow( DisplayLocation(geo, nav) } - val baseReward = remember(noteEvent) { noteEvent.getReward()?.let { Reward(it) } } + val baseReward = remember(noteEvent) { noteEvent.bountyBaseReward()?.let { Reward(it) } } if (baseReward != null) { Spacer(StdHorzSpacer) DisplayReward(baseReward, note, accountViewModel, nav) @@ -1022,7 +1021,7 @@ fun DisplayDraftChat() { Text( "Draft", color = MaterialTheme.colorScheme.placeholderText, - modifier = HalfEndPadding, + modifier = Modifier, fontWeight = FontWeight.Bold, fontSize = Font12SP, maxLines = 1, 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 02dd65b5fb..49c617e1cf 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 @@ -95,8 +95,8 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.WarningColor import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.bounties.getReward +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.nip19Bech32.toNAddr import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent @@ -124,14 +124,14 @@ val njumpLink = { nip19BechAddress: String -> val externalLinkForNote = { note: Note -> if (note is AddressableNote) { - if (note.event?.getReward() != null) { - "https://nostrbounties.com/b/${note.address().toNAddr()}" + if (note.event?.bountyBaseReward() != null) { + "https://nostrbounties.com/b/${note.toNAddr()}" } else if (note.event is PeopleListEvent) { - "https://listr.lol/a/${note.address().toNAddr()}" + "https://listr.lol/a/${note.toNAddr()}" } else if (note.event is AudioTrackEvent) { - "https://zapstr.live/?track=${note.address().toNAddr()}" + "https://zapstr.live/?track=${note.toNAddr()}" } else { - njumpLink(note.address().toNAddr()) + njumpLink(note.toNAddr()) } } else { if (note.event is FileHeaderEvent) { 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 518f766cae..6d8bb6f525 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 @@ -97,7 +97,7 @@ import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -124,7 +124,7 @@ fun PollNotePreview() { arrayOf("poll_option", "2", "OP3"), arrayOf("value_maximum", "2"), arrayOf("value_minimum", "2"), - AltTagSerializer.toTagArray("Poll event"), + arrayOf("alt", "Poll event"), ), ) @@ -196,7 +196,7 @@ fun PollNotePreview2() { arrayOf("poll_option", "1", "Pesquisa em ingles"), arrayOf("value_maximum", "2"), arrayOf("value_minimum", "2"), - AltTagSerializer.toTagArray("Poll event"), + AltTag.assemble("Poll event"), ), ) @@ -627,7 +627,11 @@ fun ZapVote( title = toast.title, textContent = toast.msg, onClickStartMessage = { - baseNote.author?.let { nav.nav(routeToMessage(it, toast.msg, accountViewModel)) } + baseNote.author?.let { + nav.nav { + routeToMessage(it, toast.msg, accountViewModel = accountViewModel) + } + } }, onDismiss = { showErrorMessageDialog = null }, ) 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 942278a7ad..f9476e789d 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 @@ -28,11 +28,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.experimental.zapPolls.CLOSED_AT -import com.vitorpamplona.quartz.experimental.zapPolls.CONSENSUS_THRESHOLD import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.experimental.zapPolls.VALUE_MAXIMUM -import com.vitorpamplona.quartz.experimental.zapPolls.VALUE_MINIMUM import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -81,17 +77,12 @@ class PollNoteViewModel : ViewModel() { pollNote = note pollEvent = pollNote?.event as PollNoteEvent pollOptions = pollEvent?.pollOptions() - valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM) - valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM) + valueMaximum = pollEvent?.maxAmount() + valueMinimum = pollEvent?.minAmount() valueMinimumBD = valueMinimum?.let { BigDecimal(it) } valueMaximumBD = valueMaximum?.let { BigDecimal(it) } - consensusThreshold = - pollEvent - ?.getTagLong(CONSENSUS_THRESHOLD) - ?.toFloat() - ?.div(100) - ?.toBigDecimal() - closedAt = pollEvent?.getTagLong(CLOSED_AT) + consensusThreshold = pollEvent?.consensusThreshold()?.toBigDecimal() + closedAt = pollEvent?.closedAt() totalZapped = BigDecimal.ZERO wasZappedByLoggedInAccount = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt index f873e70411..29be5a6779 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.note -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey fun ByteArray.toShortenHex(): String = toHexKey().toShortenHex() 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 af5e11a53f..9a9e06a76a 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 @@ -101,16 +101,19 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute +import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -147,7 +150,9 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.reactionBox import com.vitorpamplona.amethyst.ui.theme.ripple24dp import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import kotlinx.collections.immutable.ImmutableList @@ -209,13 +214,16 @@ private fun InnerReactionRow( ) }, three = { - BoostWithDialog( - baseNote, - editState, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav, - ) + val isDM = baseNote.event is ChatroomKeyable + if (!isDM) { + BoostWithDialog( + baseNote, + editState, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav, + ) + } }, four = { LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) @@ -558,56 +566,46 @@ private fun BoostWithDialog( accountViewModel: AccountViewModel, nav: INav, ) { - var wantsToQuote by remember { mutableStateOf(null) } - var wantsToFork by remember { mutableStateOf(null) } - - if (wantsToQuote != null) { - val route = - buildNewPostRoute( - quote = wantsToQuote?.idHex, - version = - (editState.value as? GenericLoadable.Loaded) - ?.loaded - ?.modificationToShow - ?.value - ?.idHex, - ) - nav.nav(route) - } - - if (wantsToFork != null) { - val replyTo = - remember(wantsToFork) { - val forkEvent = wantsToFork?.event - if (forkEvent is BaseTextNoteEvent) { - val hex = forkEvent.replyingTo() - wantsToFork?.replyTo?.filter { it.event?.id == hex }?.firstOrNull() - } else { - null - } - } - - val route = - buildNewPostRoute( - quote = wantsToQuote?.idHex, - baseReplyTo = replyTo?.idHex, - fork = wantsToFork?.idHex, - version = - (editState.value as? GenericLoadable.Loaded) - ?.loaded - ?.modificationToShow - ?.value - ?.idHex, - ) - nav.nav(route) - } - BoostReaction( baseNote, grayTint, accountViewModel, - onQuotePress = { wantsToQuote = baseNote }, - onForkPress = { wantsToFork = baseNote }, + onQuotePress = { + nav.nav { + buildNewPostRoute( + quote = baseNote.idHex, + version = + (editState.value as? GenericLoadable.Loaded) + ?.loaded + ?.modificationToShow + ?.value + ?.idHex, + ) + } + }, + onForkPress = { + nav.nav { + val forkEvent = baseNote.event + val replyTo = + if (forkEvent is BaseThreadedEvent) { + val hex = forkEvent.replyingTo() + baseNote.replyTo?.filter { it.event?.id == hex }?.firstOrNull() + } else { + null + } + + buildNewPostRoute( + baseReplyTo = replyTo?.idHex, + fork = baseNote.idHex, + version = + (editState.value as? GenericLoadable.Loaded) + ?.loaded + ?.modificationToShow + ?.value + ?.idHex, + ) + } + }, ) } @@ -618,18 +616,37 @@ private fun ReplyReactionWithDialog( accountViewModel: AccountViewModel, nav: INav, ) { - var wantsToReplyTo by remember { mutableStateOf(null) } - - if (wantsToReplyTo != null) { - val route = - buildNewPostRoute( - baseReplyTo = wantsToReplyTo?.idHex, - quote = null, - ) - nav.nav(route) + ReplyReaction(baseNote, grayTint, accountViewModel) { + val noteEvent = baseNote.event + if (noteEvent is PrivateDmEvent) { + nav.nav { + routeToMessage( + room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex), + draftMessage = null, + replyId = noteEvent.id, + quoteId = null, + accountViewModel = accountViewModel, + ) + } + } else if (noteEvent is ChatroomKeyable) { + nav.nav { + routeToMessage( + room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex), + draftMessage = null, + replyId = noteEvent.id, + quoteId = null, + accountViewModel = accountViewModel, + ) + } + } else { + nav.nav { + buildNewPostRoute( + baseReplyTo = baseNote.idHex, + quote = null, + ) + } + } } - - ReplyReaction(baseNote, grayTint, accountViewModel) { wantsToReplyTo = baseNote } } @Composable @@ -938,7 +955,16 @@ private fun RenderReactionType( when (reactionType) { "+" -> LikedIcon(iconSizeModifier) "-" -> Text(text = "\uD83D\uDC4E", maxLines = 1, fontSize = iconFontSize) - else -> Text(text = reactionType, maxLines = 1, fontSize = iconFontSize) + else -> { + if (EmojiCoder.isCoded(reactionType)) { + AnimatedBorderTextCornerRadius( + reactionType, + fontSize = iconFontSize, + ) + } else { + Text(text = reactionType, maxLines = 1, fontSize = iconFontSize) + } + } } } } @@ -1486,6 +1512,7 @@ fun ReactionChoicePopupPeeview() { "\uD83D\uDE31", "\uD83E\uDD14", "\uD83D\uDE31", + "\uD83D\uDE80\uDB40\uDD58\uDB40\uDD64\uDB40\uDD64\uDB40\uDD60\uDB40\uDD63\uDB40\uDD2A\uDB40\uDD1F\uDB40\uDD1F\uDB40\uDD53\uDB40\uDD54\uDB40\uDD5E\uDB40\uDD1E\uDB40\uDD63\uDB40\uDD51\uDB40\uDD64\uDB40\uDD55\uDB40\uDD5C\uDB40\uDD5C\uDB40\uDD59\uDB40\uDD64\uDB40\uDD55\uDB40\uDD1E\uDB40\uDD55\uDB40\uDD51\uDB40\uDD62\uDB40\uDD64\uDB40\uDD58\uDB40\uDD1F\uDB40\uDD29\uDB40\uDD24\uDB40\uDD27\uDB40\uDD55\uDB40\uDD24\uDB40\uDD51\uDB40\uDD52\uDB40\uDD22\uDB40\uDD54\uDB40\uDD23\uDB40\uDD21\uDB40\uDD21\uDB40\uDD25\uDB40\uDD52\uDB40\uDD55\uDB40\uDD25\uDB40\uDD26\uDB40\uDD25\uDB40\uDD51\uDB40\uDD24\uDB40\uDD29\uDB40\uDD53\uDB40\uDD56\uDB40\uDD25\uDB40\uDD54\uDB40\uDD52\uDB40\uDD20\uDB40\uDD22\uDB40\uDD25\uDB40\uDD25\uDB40\uDD29\uDB40\uDD56\uDB40\uDD23\uDB40\uDD21\uDB40\uDD20\uDB40\uDD53\uDB40\uDD51\uDB40\uDD20\uDB40\uDD51\uDB40\uDD26\uDB40\uDD54\uDB40\uDD54\uDB40\uDD56\uDB40\uDD54\uDB40\uDD54\uDB40\uDD52\uDB40\uDD54\uDB40\uDD24\uDB40\uDD52\uDB40\uDD54\uDB40\uDD28\uDB40\uDD53\uDB40\uDD52\uDB40\uDD55\uDB40\uDD53\uDB40\uDD24\uDB40\uDD24\uDB40\uDD29\uDB40\uDD29\uDB40\uDD25\uDB40\uDD53\uDB40\uDD22\uDB40\uDD55\uDB40\uDD27\uDB40\uDD1E\uDB40\uDD67\uDB40\uDD55\uDB40\uDD52\uDB40\uDD60", ), onClick = {}, onChangeAmount = {}, @@ -1539,12 +1566,20 @@ fun RenderReaction(reactionType: String) { ) } else -> { - Text( - reactionType, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - fontSize = 22.sp, - ) + if (EmojiCoder.isCoded(reactionType)) { + AnimatedBorderTextCornerRadius( + reactionType, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 20.sp, + ) + } else { + Text( + reactionType, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + fontSize = 22.sp, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ShowEmojiSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ShowEmojiSuggestionList.kt index aae7ebd3b7..fa5a6bfb5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ShowEmojiSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ShowEmojiSuggestionList.kt @@ -54,14 +54,14 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @Composable fun WatchAndLoadMyEmojiList(accountViewModel: AccountViewModel) { LoadAddressableNote( - EmojiPackSelectionEvent.createAddressATag(accountViewModel.userProfile().pubkeyHex), + EmojiPackSelectionEvent.createAddress(accountViewModel.userProfile().pubkeyHex), accountViewModel, ) { emptyNote -> emptyNote?.let { usersEmojiList -> @@ -73,7 +73,7 @@ fun WatchAndLoadMyEmojiList(accountViewModel: AccountViewModel) { .observeAsState((usersEmojiList.event as? EmojiPackSelectionEvent)?.taggedAddresses()?.toImmutableList()) collections?.forEach { - LoadAddressableNote(aTag = it, accountViewModel) { + LoadAddressableNote(it, accountViewModel) { it?.live()?.metadata?.observeAsState() } } 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 2aba7ad5cb..d1e2162b02 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 @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding @@ -44,11 +45,15 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -73,10 +78,16 @@ import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.firstFullChar +import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius +import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer +import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack @@ -85,12 +96,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -110,13 +121,19 @@ class UpdateReactionTypeViewModel : ViewModel() { fun toListOfChoices(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } fun addChoice() { - val newValue = nextChoice.text.trim().firstFullChar() + val newValue = + if (EmojiCoder.isCoded(nextChoice.text)) { + EmojiCoder.cropToFirstMessage(nextChoice.text) + } else { + nextChoice.text.trim().firstFullChar() + } + reactionSet = reactionSet + newValue nextChoice = TextFieldValue("") } - fun addChoice(customEmoji: EmojiUrl) { + fun addChoice(customEmoji: EmojiUrlTag) { reactionSet = reactionSet + (customEmoji.encode()) } @@ -157,7 +174,7 @@ fun UpdateReactionTypeDialog( UpdateReactionTypeDialog(postViewModel, onClose, accountViewModel, nav) } -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable fun UpdateReactionTypeDialog( postViewModel: UpdateReactionTypeViewModel, @@ -174,98 +191,112 @@ fun UpdateReactionTypeDialog( decorFitsSystemWindows = false, ), ) { - Surface( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(10.dp).imePadding(), + SetDialogToEdgeToEdge() + + Scaffold( + topBar = { + TopAppBar( + actions = { + SaveButton( + onPost = { + postViewModel.sendPost() + onClose() + }, + isActive = postViewModel.hasChanged(), + ) + Spacer(modifier = StdHorzSpacer) + }, + title = {}, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier.padding(10.dp), ) { - CloseButton( - onPress = { - postViewModel.cancel() - onClose() - }, - ) - - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged(), - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), + Row( + modifier = Modifier.fillMaxWidth(), ) { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - postViewModel.reactionSet.forEach { reactionType -> - RenderReactionOption(reactionType, postViewModel) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + postViewModel.reactionSet.forEach { reactionType -> + RenderReactionOption(reactionType, postViewModel) + } } } } - } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(10.dp)) - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.new_reaction_symbol)) }, - value = postViewModel.nextChoice, - onValueChange = { postViewModel.nextChoice = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text, - ), - placeholder = { - Text( - text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.padding(end = 10.dp).weight(1f), - ) - - Button( - onClick = { postViewModel.addChoice() }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text(text = stringRes(R.string.add), color = Color.White) + OutlinedTextField( + label = { Text(text = stringRes(R.string.new_reaction_symbol)) }, + value = postViewModel.nextChoice, + onValueChange = { postViewModel.nextChoice = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + placeholder = { + Text( + text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 10.dp).weight(1f), + ) + + Button( + onClick = { postViewModel.addChoice() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringRes(R.string.add), color = Color.White) + } } } } - } - EmojiSelector( - accountViewModel = accountViewModel, - nav = nav, - ) { - postViewModel.addChoice(it) + Spacer(StdVertSpacer) + + EmojiSelector( + accountViewModel = accountViewModel, + nav = nav, + ) { + postViewModel.addChoice(it) + } } } } @@ -322,14 +353,77 @@ private fun RenderReactionOption( color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) - else -> - Text( - text = "$reactionType ✖", - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, + else -> { + if (EmojiCoder.isCoded(reactionType)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AnimatedBorderTextCornerRadius( + reactionType, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + Text( + text = " ✖", + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + } + } else { + Text( + text = "$reactionType ✖", + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + } + } + } + } + } +} + +@Composable +fun DisplaySecretEmoji( + text: String, + state: RichTextViewerState, + callbackUri: String?, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (canPreview && quotesLeft > 0) { + var secretContent by remember { + mutableStateOf(null) + } + + var showPopup by remember { + mutableStateOf(false) + } + + LaunchedEffect(text) { + launch(Dispatchers.Default) { + secretContent = + CachedRichTextParser.parseText( + EmojiCoder.decode(text), + state.tags, ) } } + + val localSecretContent = secretContent + + AnimatedBorderTextCornerRadius( + text, + Modifier.clickable { + showPopup = !showPopup + }, + ) + + if (localSecretContent != null && showPopup) { + CoreSecretMessage(localSecretContent, callbackUri, quotesLeft, backgroundColor, accountViewModel, nav) + } + } else { + Text(text) } } @@ -337,16 +431,10 @@ private fun RenderReactionOption( private fun EmojiSelector( accountViewModel: AccountViewModel, nav: INav, - onClick: ((EmojiUrl) -> Unit)? = null, + onClick: ((EmojiUrlTag) -> Unit)? = null, ) { LoadAddressableNote( - aTag = - ATag( - EmojiPackSelectionEvent.KIND, - accountViewModel.userProfile().pubkeyHex, - "", - null, - ), + accountViewModel.account.getEmojiPackSelectionAddress(), accountViewModel, ) { emptyNote -> emptyNote?.let { usersEmojiList -> @@ -354,11 +442,11 @@ private fun EmojiSelector( usersEmojiList .live() .metadata - .map { (it.note.event as? EmojiPackSelectionEvent)?.taggedAddresses()?.toImmutableList() } + .map { (it.note.event as? EmojiPackSelectionEvent)?.emojiPackIds()?.toImmutableList() } .distinctUntilChanged() .observeAsState( (usersEmojiList.event as? EmojiPackSelectionEvent) - ?.taggedAddresses() + ?.emojiPackIds() ?.toImmutableList(), ) @@ -369,10 +457,10 @@ private fun EmojiSelector( @Composable fun EmojiCollectionGallery( - emojiCollections: ImmutableList, + emojiCollections: ImmutableList, accountViewModel: AccountViewModel, nav: INav, - onClick: ((EmojiUrl) -> Unit)? = null, + onClick: ((EmojiUrlTag) -> Unit)? = null, ) { val color = MaterialTheme.colorScheme.background val bgColor = remember { mutableStateOf(color) } @@ -382,8 +470,8 @@ fun EmojiCollectionGallery( LazyColumn( state = listState, ) { - itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item -> - LoadAddressableNote(aTag = item, accountViewModel) { + itemsIndexed(emojiCollections, key = { _, item -> item }) { _, item -> + LoadAddressableNote(item, accountViewModel) { it?.let { WatchAndRenderNote(it, bgColor, accountViewModel, nav, onClick) } } } @@ -396,7 +484,7 @@ private fun WatchAndRenderNote( bgColor: MutableState, accountViewModel: AccountViewModel, nav: INav, - onClick: ((EmojiUrl) -> Unit)?, + onClick: ((EmojiUrlTag) -> Unit)?, ) { val scope = rememberCoroutineScope() 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 f7b4ff8ee7..bc1d432804 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 @@ -98,7 +98,7 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 0e3fbc5f07..e0be54e8da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -44,9 +44,9 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @Composable fun NoteAuthorPicture( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatter.kt index 0ad29ac7f1..e5024ad675 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatter.kt @@ -74,3 +74,10 @@ fun showAmount(amount: BigDecimal?): String { else -> dfN.get().format(amount) } } + +fun showAmountWithZero(amount: BigDecimal?): String { + if (amount == null) return "0" + if (amount.abs() < BigDecimal(0.01)) return "0" + + return showAmount(amount) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt new file mode 100644 index 0000000000..d36f532420 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat + +private val dfG = + object : ThreadLocal() { + override fun initialValue() = DecimalFormat("#G") + } + +private val dfM = + object : ThreadLocal() { + override fun initialValue() = DecimalFormat("#M") + } + +private val dfK = + object : ThreadLocal() { + override fun initialValue() = DecimalFormat("#k") + } + +private val dfN = + object : ThreadLocal() { + override fun initialValue() = DecimalFormat("#") + } + +fun showAmountInteger(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= OneGiga -> dfG.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfM.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.get().format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.get().format(amount) + } +} + +fun showAmountIntegerWithZero(amount: BigDecimal?): String { + if (amount == null) return "0" + if (amount.abs() < BigDecimal(0.01)) return "0" + + return showAmountInteger(amount) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index b58a236922..73efdf414d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -47,12 +47,11 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ShowUserButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.WatchIsHiddenUser -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ZapReqResponse +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.WatchIsHiddenUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -144,7 +143,7 @@ private fun ZapAmount(zapEventNote: Note) { LaunchedEffect(key1 = noteState) { launch(Dispatchers.IO) { - val newZapAmount = showAmountAxis((noteState?.note?.event as? LnZapEvent)?.amount) + val newZapAmount = showAmountInteger((noteState?.note?.event as? LnZapEvent)?.amount) if (zapAmount != newZapAmount) { zapAmount = newZapAmount } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt index 3f29bca70b..8132ccda08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt @@ -51,8 +51,8 @@ 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.HexKey -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @Preview @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt index 69b85288a9..83cfc46ff6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt @@ -51,7 +51,7 @@ 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.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent @Preview diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index e8879681e3..f1c4df4ae3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.getTagOfAddressableKind -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @Composable fun DisplayFollowingCommunityInPost( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 46c4344841..64765a8630 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -177,24 +177,19 @@ class AddBountyAmountViewModel : ViewModel() { } fun sendPost() { - val newValue = nextAmount.text.trim().toLongOrNull() + val newValue = nextAmount.text.trim().toBigDecimalOrNull() if (newValue != null) { viewModelScope.launch { - account?.let { - it.sendPost( - message = newValue.toString(), - replyTo = listOfNotNull(bounty), - mentions = listOfNotNull(bounty?.author), - tags = listOf("bounty-added-reward"), - wantsToMarkAsSensitive = false, - replyingTo = null, - root = null, - directMentions = setOf(), - forkedFrom = null, - draftTag = null, - relayList = it.activeWriteRelays().toImmutableList(), - ) + account?.let { myAccount -> + bounty?.let { bountyInner -> + myAccount.sendAddBounty( + newValue, + bountyInner, + draftTag = null, + relayList = myAccount.activeWriteRelays().toImmutableList(), + ) + } } nextAmount = TextFieldValue("") 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 4a6376a248..5d734079ea 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 @@ -45,11 +45,13 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.nip05 -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent +import com.vitorpamplona.quartz.experimental.forks.forkFromAddress +import com.vitorpamplona.quartz.experimental.forks.forkFromVersion +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent @Composable fun ShowForkInformation( - noteEvent: BaseTextNoteEvent, + noteEvent: BaseThreadedEvent, modifier: Modifier, accountViewModel: AccountViewModel, nav: INav, @@ -57,16 +59,13 @@ fun ShowForkInformation( val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } val forkedEvent = remember(noteEvent) { noteEvent.forkFromVersion() } if (forkedAddress != null) { - LoadAddressableNote( - aTag = forkedAddress, - accountViewModel = accountViewModel, - ) { addressableNote -> + LoadAddressableNote(forkedAddress, accountViewModel) { addressableNote -> if (addressableNote != null) { ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav) } } } else if (forkedEvent != null) { - LoadNote(forkedEvent, accountViewModel = accountViewModel) { event -> + LoadNote(forkedEvent.eventId, accountViewModel) { event -> if (event != null) { ForkInformationRowLightColor(event, modifier, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index 907ff96129..3ee743da6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -72,8 +72,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppMetadata +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index 704dd48a2a..3535caff28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -51,15 +51,12 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.audio.Participant +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audio.track.tags.ParticipantTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists import com.vitorpamplona.quartz.nip14Subject.subject -import kotlinx.collections.immutable.toImmutableList -import java.util.Locale @Composable fun RenderAudioTrack( @@ -84,10 +81,9 @@ fun AudioTrackHeader( val media = remember { noteEvent.media() } val cover = remember { noteEvent.cover() } val subject = remember { noteEvent.subject() } - val content = remember { noteEvent.content } val participants = remember { noteEvent.participants() } - var participantUsers by remember { mutableStateOf>>(emptyList()) } + var participantUsers by remember { mutableStateOf>>(emptyList()) } LaunchedEffect(key1 = participants) { accountViewModel.loadParticipants(participants) { participantUsers = it } @@ -129,13 +125,6 @@ fun AudioTrackHeader( Spacer(Modifier.width(5.dp)) UsernameDisplay(it.second, Modifier.weight(1f), accountViewModel = accountViewModel) Spacer(Modifier.width(5.dp)) - it.first.role?.let { - Text( - text = it.capitalize(Locale.ROOT), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - } } } @@ -193,7 +182,7 @@ fun AudioHeader( nav: INav, ) { val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } + val waveform = remember { noteEvent.wavefrom() } val content = remember { noteEvent.content.ifBlank { null } } val defaultBackground = MaterialTheme.colorScheme.background diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index ae2b1dd4a7..366bd79469 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -155,7 +155,7 @@ fun RenderBadgeAward( Text(text = stringRes(R.string.award_granted_to)) - LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } } + LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardeeIds()) { awardees = it } } FlowRow(modifier = Modifier.padding(top = 5.dp)) { awardees.take(100).forEach { user -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt index 0f2a5eff6d..ba40ae89f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt @@ -33,10 +33,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @Composable fun RenderChannelMessage( @@ -53,7 +53,7 @@ fun RenderChannelMessage( val showChannelInfo = remember(noteEvent) { if (noteEvent is ChannelMessageEvent) { - noteEvent.channel() + noteEvent.channelId() } else { null } 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 9e3c1705e4..4b66a1e197 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 @@ -36,10 +36,10 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @Composable fun RenderChatMessage( 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 c60a99b211..70f58baa63 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 @@ -49,16 +49,16 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList -import com.vitorpamplona.quartz.nip17Dm.AESGCM -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEncryptedFileHeaderEvent -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable -import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM +import com.vitorpamplona.quartz.nip31Alts.alt import kotlinx.collections.immutable.persistentListOf @Composable @@ -112,13 +112,13 @@ fun RenderEncryptedFile( val algo = noteEvent.algo() val key = noteEvent.key() val nonce = noteEvent.nonce() + val mimeType = noteEvent.mimeType() if (algo == AESGCM.NAME && key != null && nonce != null) { - HttpClientManager.addCipherToCache(noteEvent.content, AESGCM(key, nonce)) + HttpClientManager.addCipherToCache(noteEvent.content, AESGCM(key, nonce), mimeType) val content by remember(noteEvent) { - val isImage = noteEvent.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content) - val mimeType = noteEvent.mimeType() + val isImage = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content) mutableStateOf( if (isImage) { @@ -128,7 +128,7 @@ fun RenderEncryptedFile( hash = noteEvent.originalHash(), blurhash = noteEvent.blurhash(), dim = noteEvent.dimensions(), - uri = noteEvent.toNostrUri(), + uri = note.toNostrUri(), mimeType = mimeType, encryptionAlgo = algo, encryptionKey = key, 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 528d391883..488c3ee2c4 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 @@ -61,9 +61,9 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.JoinCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LeaveCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.NormalTimeAgo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.JoinCommunityButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.LeaveCommunityButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.NormalTimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -75,11 +75,11 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.innerPostModifier -import com.vitorpamplona.quartz.experimental.audio.Participant import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip14Subject.subject -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import java.util.Locale @@ -196,7 +196,7 @@ fun LongCommunityHeader( var participantUsers by remember(baseNote) { - mutableStateOf>>( + mutableStateOf>>( persistentListOf(), ) } @@ -265,7 +265,7 @@ fun ShortCommunityHeader( noteEvent.image()?.let { RobohashFallbackAsyncImage( robot = baseNote.idHex, - model = it, + model = it.imageUrl, contentDescription = stringRes(R.string.profile_image), contentScale = ContentScale.Crop, modifier = HeaderPictureModifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt index 41cbe7dd63..df9712d1d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -21,14 +21,15 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -40,6 +41,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -56,11 +58,9 @@ import com.vitorpamplona.amethyst.ui.note.elements.RemoveButton import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size35Modifier -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis @Composable @@ -69,7 +69,7 @@ public fun RenderEmojiPack( actionable: Boolean, backgroundColor: MutableState, accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null, + onClick: ((EmojiUrlTag) -> Unit)? = null, ) { val noteEvent by baseNote @@ -101,7 +101,7 @@ public fun RenderEmojiPack( actionable: Boolean, backgroundColor: MutableState, accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null, + onClick: ((EmojiUrlTag) -> Unit)? = null, ) { var expanded by remember { mutableStateOf(false) } @@ -114,7 +114,7 @@ public fun RenderEmojiPack( allEmojis.take(60) } - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 10.dp)) { Text( text = remember(noteEvent) { "#${noteEvent.dTag()}" }, fontWeight = FontWeight.Bold, @@ -133,16 +133,19 @@ public fun RenderEmojiPack( } Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { + FlowRow( + modifier = Modifier.padding(top = 5.dp), + verticalArrangement = spacedBy(1.dp), + horizontalArrangement = spacedBy(1.dp), + ) { emojisToShow.forEach { emoji -> if (onClick != null) { - IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { - AsyncImage( - model = emoji.url, - contentDescription = emoji.code, - modifier = Size35Modifier, - ) - } + AsyncImage( + model = emoji.url, + contentDescription = emoji.code, + modifier = Size35Modifier.clickable { onClick(emoji) }, + contentScale = ContentScale.Crop, + ) } else { Box( modifier = Size35Modifier, @@ -152,6 +155,7 @@ public fun RenderEmojiPack( model = emoji.url, contentDescription = emoji.code, modifier = Size35Modifier, + contentScale = ContentScale.Crop, ) } } @@ -180,13 +184,7 @@ private fun EmojiListOptions( emojiPackNote: Note, ) { LoadAddressableNote( - aTag = - ATag( - EmojiPackSelectionEvent.KIND, - accountViewModel.userProfile().pubkeyHex, - "", - null, - ), + accountViewModel.account.getEmojiPackSelectionAddress(), accountViewModel, ) { it?.let { usersEmojiList -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 11410be709..cb69b0d895 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt index b608b19033..dca60e57e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -37,7 +37,8 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip31Alts.alt import java.io.File @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index fcab0a63fa..17387b2236 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -66,9 +66,9 @@ import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject -import com.vitorpamplona.quartz.nip34Git.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent @Composable fun RenderGitPatchEvent( @@ -137,10 +137,10 @@ private fun RenderGitPatchEvent( accountViewModel: AccountViewModel, nav: INav, ) { - val repository = remember(noteEvent) { noteEvent.repository() } + val repository = remember(noteEvent) { noteEvent.repositoryAddress() } if (repository != null) { - LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { + LoadAddressableNote(repository, accountViewModel) { if (it != null) { RenderShortRepositoryHeader(it, accountViewModel, nav) Spacer(modifier = DoubleVertSpacer) @@ -242,10 +242,10 @@ private fun RenderGitIssueEvent( accountViewModel: AccountViewModel, nav: INav, ) { - val repository = remember(noteEvent) { noteEvent.repository() } + val repository = remember(noteEvent) { noteEvent.repositoryAddress() } if (repository != null) { - LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { + LoadAddressableNote(repository, accountViewModel) { if (it != null) { RenderShortRepositoryHeader(it, accountViewModel, nav) Spacer(modifier = DoubleVertSpacer) 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 eaa07279ba..06e0dbcdac 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 @@ -54,11 +54,10 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor -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.nip02FollowList.EmptyTagList -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip19Bech32.toNIP19 +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -79,9 +78,9 @@ fun RenderHighlight( DisplayHighlight( highlight = noteEvent.quote(), context = noteEvent.context(), - authorHex = noteEvent.author(), + authorHex = noteEvent.pubKey, url = noteEvent.inUrl(), - postAddress = noteEvent.inPost(), + postAddress = noteEvent.inPostAddress(), postVersion = noteEvent.inPostVersion(), makeItShort = makeItShort, canPreview = canPreview, @@ -99,7 +98,7 @@ fun DisplayHighlight( context: String?, authorHex: String?, url: String?, - postAddress: ATag?, + postAddress: Address?, postVersion: ETag?, makeItShort: Boolean, canPreview: Boolean, @@ -149,7 +148,7 @@ private fun DisplayQuoteAuthor( highlightQuote: String, authorHex: String?, baseUrl: String?, - postAddress: ATag?, + postAddress: Address?, postVersion: ETag?, accountViewModel: AccountViewModel, nav: INav, @@ -165,7 +164,7 @@ private fun DisplayQuoteAuthor( } var addressable by remember { - mutableStateOf(postAddress?.let { accountViewModel.getAddressableNoteIfExists(it.toTag()) }) + mutableStateOf(postAddress?.let { accountViewModel.getAddressableNoteIfExists(it) }) } if (addressable == null && postAddress != null) { @@ -247,7 +246,7 @@ fun DisplayEntryForNote( RenderUserAsClickableText(author, null, nav) } - val noteEvent = noteState?.note?.event as? BaseTextNoteEvent ?: return + val noteEvent = noteState?.note?.event as? BaseThreadedEvent ?: return val description = remember(noteEvent) { noteEvent.tags.firstTagValueFor("title", "subject", "alt") } @@ -260,7 +259,7 @@ fun DisplayEntryForNote( style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), ) } else { - DisplayEvent(noteEvent.id, noteEvent.kind, noteEvent.toNIP19(), null, accountViewModel, nav) + DisplayEvent(noteEvent.id, noteEvent.kind, note.toNostrUri(), null, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt index d5e1a5beb5..de6814f1a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt @@ -45,7 +45,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList @Composable @@ -66,7 +66,7 @@ fun RenderInteractiveStory( val rootEvent = note.value?.note?.event as? InteractiveStoryBaseEvent ?: return // keep updating the reading state event with new versions - val readingStateNote = accountViewModel.getInteractiveStoryReadingState(address.toTag()) + val readingStateNote = accountViewModel.getInteractiveStoryReadingState(address.toValue()) val latestReadingNoteState = readingStateNote.live().metadata.observeAsState() val readingState = latestReadingNoteState.value?.note?.event as? InteractiveStoryReadingStateEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 98a9dfed51..992e4f63c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -58,8 +58,8 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ScheduledFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -69,9 +69,10 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.experimental.audio.Participant import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -159,11 +160,11 @@ fun RenderLiveActivityEventInner( CrossfadeIfEnabled(targetState = status, label = "RenderLiveActivityEventInner", accountViewModel = accountViewModel) { when (it) { - LiveActivitiesEvent.STATUS_LIVE -> { + StatusTag.STATUS.LIVE.code -> { media?.let { CrossfadeCheckIfVideoIsOnline(it, accountViewModel) { LiveFlag() } } } - LiveActivitiesEvent.STATUS_PLANNED -> { + StatusTag.STATUS.PLANNED.code -> { ScheduledFlag(starts) } } @@ -171,7 +172,7 @@ fun RenderLiveActivityEventInner( } media?.let { media -> - if (status == LiveActivitiesEvent.STATUS_LIVE) { + if (status == StatusTag.STATUS.LIVE.code) { CheckIfVideoIsOnline(media, accountViewModel) { isOnline -> if (isOnline) { Row( @@ -206,7 +207,7 @@ fun RenderLiveActivityEventInner( } } } else { - if (status == LiveActivitiesEvent.STATUS_ENDED || (status == LiveActivitiesEvent.STATUS_PLANNED && (starts ?: 0) < TimeUtils.eightHoursAgo())) { + if (status == StatusTag.STATUS.ENDED.code || (status == StatusTag.STATUS.PLANNED.code && (starts ?: 0) < TimeUtils.eightHoursAgo())) { Box( contentAlignment = Alignment.Center, modifier = @@ -233,7 +234,7 @@ fun RenderLiveActivityEventInner( } var participantUsers by remember { - mutableStateOf>>( + mutableStateOf>>( persistentListOf(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt index 91e3f9e0fe..36d67889cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt @@ -33,10 +33,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @Composable fun RenderLiveActivityChatMessage( 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 d78900ec20..4b47074ed6 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 @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -94,7 +95,7 @@ fun DisplayPeopleList( ) LaunchedEffect(Unit) { - accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { + accountViewModel.loadUsers(noteEvent.taggedUserIds()) { members = it } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 555b2eae34..090ce4d945 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -47,7 +47,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @Composable fun RenderPoll( 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 da83105547..3f8157de37 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 @@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -51,8 +51,8 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip19Bech32.toNpub @Composable 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 6cbc97a19c..df646155b5 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 @@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt index 05517f21e4..8b0b10280c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt @@ -41,7 +41,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @Composable fun RenderPostApproval( @@ -56,7 +56,7 @@ fun RenderPostApproval( val noteEvent = note.event as? CommunityPostApprovalEvent ?: return Column(Modifier.fillMaxWidth()) { - noteEvent.communities().forEach { tag -> + noteEvent.communityAddresses().forEach { tag -> LoadAddressableNote(tag, accountViewModel) { baseNote -> baseNote?.let { RenderCommunity( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 6157de2b6e..4346317530 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -49,10 +49,10 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @Composable fun RenderTextEvent( @@ -71,14 +71,14 @@ fun RenderTextEvent( val showReply by remember(note) { derivedStateOf { - noteEvent is BaseTextNoteEvent && !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) + noteEvent is BaseThreadedEvent && !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) } } if (showReply) { val replyingDirectlyTo = remember(note) { - if (noteEvent is BaseTextNoteEvent) { + if (noteEvent is BaseThreadedEvent) { val replyingTo = noteEvent.replyingToAddressOrEvent() if (replyingTo != null) { val newNote = accountViewModel.getNoteIfExists(replyingTo) 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 ebdbd62a7f..cef42b915d 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 @@ -84,7 +84,7 @@ fun RenderTextModificationEvent( val isAuthorTheLoggedUser = remember { val authorOfTheOriginalNote = - noteEvent.editedNote()?.let { accountViewModel.getNoteIfExists(it.eventId)?.author?.pubkeyHex ?: it.authorPubKeyHex } + noteEvent.editedNote()?.let { accountViewModel.getNoteIfExists(it.eventId)?.author?.pubkeyHex ?: it.author } mutableStateOf(accountViewModel.isLoggedUser(authorOfTheOriginalNote)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt index c2902ac3db..2fbcefca73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt @@ -65,7 +65,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size30dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentFile +import com.vitorpamplona.quartz.nip35Torrents.tags.FileTag import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -177,7 +177,7 @@ fun RenderTorrent( @Composable fun DisplayFileList( - files: ImmutableList, + files: ImmutableList, name: String, description: String?, link: () -> String, 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 28dcb15228..e7444f8bf1 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 @@ -179,7 +179,7 @@ fun RenderTorrentComment( torrentInfo?.let { TorrentHeader( - torrentHex = it, + torrentHex = it.eventId, modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel = accountViewModel, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 6259245d58..882053d595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip71Video.VideoEvent @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 57c7734785..f7556eee37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip71Video.VideoEvent @Composable 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 5e2412e03b..803d7f425e 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 @@ -36,15 +36,16 @@ import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip01Core.toHexKey -import com.vitorpamplona.quartz.nip02FollowList.Contact import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip02FollowList.ReadWrite +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -55,6 +56,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Hex @@ -141,9 +143,9 @@ class AccountStateViewModel : ViewModel() { transientAccount = transientAccount, torSettings = TorSettingsFlow.build(torSettings), ) - } else if (key.contains(" ") && CryptoUtils.isValidMnemonic(key)) { + } else if (key.contains(" ") && Nip06().isValidMnemonic(key)) { AccountSettings( - keyPair = KeyPair(privKey = CryptoUtils.privateKeyFromMnemonic(key)), + keyPair = KeyPair(privKey = Nip06().privateKeyFromMnemonic(key)), transientAccount = transientAccount, torSettings = TorSettingsFlow.build(torSettings), ) @@ -205,7 +207,11 @@ class AccountStateViewModel : ViewModel() { if (key.startsWith("ncryptsec")) { val newKey = try { - CryptoUtils.decryptNIP49(key, password) + if (key.isEmpty() || password.isEmpty()) { + null + } else { + Nip49().decrypt(key, password) + } } catch (e: Exception) { if (e is CancellationException) throw e onError(e.message) @@ -279,14 +285,14 @@ class AccountStateViewModel : ViewModel() { AccountSettings( keyPair = keyPair, transientAccount = false, - backupUserMetadata = MetadataEvent.newUser(name, tempSigner), + backupUserMetadata = tempSigner.sign(MetadataEvent.newUser(name)), backupContactList = ContactListEvent.createFromScratch( - followUsers = listOf(Contact(keyPair.pubKey.toHexKey(), null)), + followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)), followEvents = DefaultChannels.toList(), relayUse = Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) + it.url to ReadWrite(it.read, it.write) }, signer = tempSigner, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 28af7ca5a5..26c9e7b220 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ThreadLevelCalculator -import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter @@ -49,17 +48,12 @@ import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryResponseFilter import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileGalleryFeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedViewModel import com.vitorpamplona.quartz.nip17Dm.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -111,34 +105,6 @@ class NostrThreadFeedViewModel( } } -class NostrUserProfileNewThreadsFeedViewModel( - val user: User, - val account: Account, -) : FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) { - class Factory( - val user: User, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileNewThreadsFeedViewModel = - NostrUserProfileNewThreadsFeedViewModel(user, account) - as NostrUserProfileNewThreadsFeedViewModel - } -} - -class NostrUserProfileConversationsFeedViewModel( - val user: User, - val account: Account, -) : FeedViewModel(UserProfileConversationsFeedFilter(user, account)) { - class Factory( - val user: User, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileConversationsFeedViewModel = - NostrUserProfileConversationsFeedViewModel(user, account) - as NostrUserProfileConversationsFeedViewModel - } -} - class NostrHashtagFeedViewModel( val hashtag: String, val account: Account, @@ -175,44 +141,6 @@ class NostrCommunityFeedViewModel( } } -class NostrUserProfileReportFeedViewModel( - val user: User, -) : FeedViewModel(UserProfileReportsFeedFilter(user)) { - class Factory( - val user: User, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileReportFeedViewModel = NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel - } -} - -class NostrUserProfileGalleryFeedViewModel( - val user: User, - val account: Account, -) : FeedViewModel(UserProfileGalleryFeedFilter(user, account)) { - class Factory( - val user: User, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileGalleryFeedViewModel = - NostrUserProfileGalleryFeedViewModel(user, account) - as NostrUserProfileGalleryFeedViewModel - } -} - -class NostrUserProfileBookmarksFeedViewModel( - val user: User, - val account: Account, -) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { - class Factory( - val user: User, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileBookmarksFeedViewModel = - NostrUserProfileBookmarksFeedViewModel(user, account) - as NostrUserProfileBookmarksFeedViewModel - } -} - @Stable class NostrBookmarkPublicFeedViewModel( val account: Account, @@ -271,18 +199,6 @@ class NostrDraftEventsFeedViewModel( } } -class NostrUserAppRecommendationsFeedViewModel( - val user: User, -) : FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { - class Factory( - val user: User, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserAppRecommendationsFeedViewModel = - NostrUserAppRecommendationsFeedViewModel(user) - as NostrUserAppRecommendationsFeedViewModel - } -} - abstract class LevelFeedViewModel( localFilter: FeedFilter, ) : FeedViewModel(localFilter) { 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 a1d9026e6f..c98795a6f2 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 @@ -34,8 +34,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -47,10 +47,10 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import kotlinx.collections.immutable.persistentListOf @@ -127,7 +127,7 @@ class FollowListState( checkNotInMainThread() val hasNewList = - newNotes.any { + newNotes.any { it -> val noteEvent = it.event noteEvent?.pubKey == account.userProfile().pubkeyHex && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt index 10a5f21d18..3294969b95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.Settings import com.vitorpamplona.amethyst.model.ThemeType import kotlinx.coroutines.Dispatchers @@ -56,6 +57,7 @@ class SettingsState { var dontShowPushNotificationSelector by mutableStateOf(false) var dontAskForNotificationPermissions by mutableStateOf(false) var featureSet by mutableStateOf(FeatureSetType.SIMPLIFIED) + var gallerySet by mutableStateOf(ProfileGalleryType.CLASSIC) var isOnMobileData: State = mutableStateOf(false) @@ -71,6 +73,14 @@ class SettingsState { } } + val modernGalleryStyle = + derivedStateOf { + when (gallerySet) { + ProfileGalleryType.CLASSIC -> false + ProfileGalleryType.MODERN -> true + } + } + val showUrlPreview = derivedStateOf { when (automaticallyShowUrlPreview) { @@ -117,6 +127,7 @@ class SharedPreferencesViewModel : ViewModel() { sharedPrefs.automaticallyShowProfilePictures = savedSettings.automaticallyShowProfilePictures sharedPrefs.dontShowPushNotificationSelector = savedSettings.dontShowPushNotificationSelector sharedPrefs.dontAskForNotificationPermissions = savedSettings.dontAskForNotificationPermissions + sharedPrefs.gallerySet = savedSettings.gallerySet sharedPrefs.featureSet = savedSettings.featureSet updateLanguageInTheUI() @@ -191,6 +202,13 @@ class SharedPreferencesViewModel : ViewModel() { } } + fun updateGallerySetType(newgalleryType: ProfileGalleryType) { + if (sharedPrefs.gallerySet != newgalleryType) { + sharedPrefs.gallerySet = newgalleryType + saveSharedSettings() + } + } + fun dontShowPushNotificationSelector() { if (sharedPrefs.dontShowPushNotificationSelector == false) { sharedPrefs.dontShowPushNotificationSelector = true @@ -237,6 +255,7 @@ class SharedPreferencesViewModel : ViewModel() { sharedPrefs.dontShowPushNotificationSelector, sharedPrefs.dontAskForNotificationPermissions, sharedPrefs.featureSet, + sharedPrefs.gallerySet, ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 9873b40f3c..3027ee01ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -34,8 +34,6 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter import com.vitorpamplona.amethyst.ui.dal.SpammerAccountsFeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.ammolite.relays.BundledUpdate @@ -48,34 +46,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class NostrUserProfileFollowsUserFeedViewModel( - val user: User, - val account: Account, -) : UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) { - class Factory( - val user: User, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileFollowsUserFeedViewModel = - NostrUserProfileFollowsUserFeedViewModel(user, account) - as NostrUserProfileFollowsUserFeedViewModel - } -} - -class NostrUserProfileFollowersUserFeedViewModel( - val user: User, - val account: Account, -) : UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) { - class Factory( - val user: User, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileFollowersUserFeedViewModel = - NostrUserProfileFollowersUserFeedViewModel(user, account) - as NostrUserProfileFollowersUserFeedViewModel - } -} - class NostrHiddenAccountsFeedViewModel( val account: Account, ) : UserFeedViewModel(HiddenAccountsFeedFilter(account)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index a632bea326..185e759003 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -102,9 +102,9 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -444,7 +444,7 @@ private fun encryptCopyNSec( } } else { accountViewModel.account.settings.keyPair.privKey?.let { - val key = CryptoUtils.encryptNIP49(it.toHexKey(), password.value.text) + val key = runCatching { Nip49().encrypt(it.toHexKey(), password.value.text) }.getOrNull() if (key != null) { clipboardManager.setText(AnnotatedString(key)) scope.launch { @@ -540,7 +540,7 @@ private fun QrCodeButtonEncrypted( onDialogShow = { accountViewModel.account.settings.keyPair.privKey ?.toHexKey() - ?.let { CryptoUtils.encryptNIP49(it, password.value.text) } + ?.let { Nip49().encrypt(it, password.value.text) } }, ) } 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 d97bb5eddf..1f3ab1d039 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 @@ -64,29 +64,29 @@ import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.SettingsState import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.ammolite.relays.BundledInsert -import com.vitorpamplona.quartz.experimental.audio.Participant import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.UserMetadata import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +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.taggedUsers +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -105,14 +105,14 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils -import fr.acinq.secp256k1.Hex import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -536,7 +536,7 @@ class AccountViewModel( it.request.event ?.content ?.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount), + showAmountInteger((it.response.event as? LnZapEvent)?.amount), ) }.toMutableMap() @@ -569,7 +569,7 @@ class AccountViewModel( ZapAmountCommentNotification( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author, cachedPrivateRequest.content.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount), + showAmountInteger((it.response.event as? LnZapEvent)?.amount), ) } else { ZapAmountCommentNotification( @@ -577,7 +577,7 @@ class AccountViewModel( it.request.event ?.content ?.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount), + showAmountInteger((it.response.event as? LnZapEvent)?.amount), ) } } else { @@ -586,7 +586,7 @@ class AccountViewModel( it.request.event ?.content ?.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount), + showAmountInteger((it.response.event as? LnZapEvent)?.amount), ) } }.toImmutableList() @@ -603,7 +603,7 @@ class AccountViewModel( ZapAmountCommentNotification( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author, cachedPrivateRequest.content.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + showAmountInteger((it.second?.event as? LnZapEvent)?.amount), ) } else { ZapAmountCommentNotification( @@ -611,7 +611,7 @@ class AccountViewModel( it.first.event ?.content ?.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + showAmountInteger((it.second?.event as? LnZapEvent)?.amount), ) } } else { @@ -620,7 +620,7 @@ class AccountViewModel( it.first.event ?.content ?.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + showAmountInteger((it.second?.event as? LnZapEvent)?.amount), ) } }.toImmutableList() @@ -642,7 +642,7 @@ class AccountViewModel( it.first.event ?.content ?.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + showAmountInteger((it.second?.event as? LnZapEvent)?.amount), ) }.toMutableMap() @@ -687,7 +687,7 @@ class AccountViewModel( ZapAmountCommentNotification( newAuthor, decryptedContent.content.ifBlank { null }, - showAmountAxis(amount), + showAmountInteger(amount), ), ) } @@ -698,7 +698,7 @@ class AccountViewModel( ZapAmountCommentNotification( zapRequest.author, zapRequest.event?.content?.ifBlank { null }, - showAmountAxis(amount), + showAmountInteger(amount), ), ) } @@ -769,9 +769,9 @@ class AccountViewModel( fun addEmojiPack( usersEmojiList: Note, - emojiList: Note, + emojiPack: Note, ) { - viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiList) } + viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiPack) } } fun addMediaToGallery( @@ -779,7 +779,7 @@ class AccountViewModel( url: String, relay: String?, blurhash: String?, - dim: Dimension?, + dim: DimensionTag?, hash: String?, mimeType: String?, ) { @@ -987,17 +987,17 @@ class AccountViewModel( } fun updateStatus( - it: ATag, + address: Address, newStatus: String, ) { viewModelScope.launch(Dispatchers.IO) { - account.updateStatus(LocalCache.getOrCreateAddressableNote(it), newStatus) + account.updateStatus(LocalCache.getOrCreateAddressableNote(address), newStatus) } } - fun deleteStatus(it: ATag) { + fun deleteStatus(address: Address) { viewModelScope.launch(Dispatchers.IO) { - account.deleteStatus(LocalCache.getOrCreateAddressableNote(it)) + account.deleteStatus(LocalCache.getOrCreateAddressableNote(address)) } } @@ -1124,10 +1124,10 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateAddressableNote(key)) } } - suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote = LocalCache.getOrCreateAddressableNote(key) + suspend fun getOrCreateAddressableNote(key: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(key) fun getOrCreateAddressableNote( - key: ATag, + key: Address, onResult: (AddressableNote?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { onResult(getOrCreateAddressableNote(key)) } @@ -1135,6 +1135,8 @@ class AccountViewModel( fun getAddressableNoteIfExists(key: String): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) + fun getAddressableNoteIfExists(key: Address): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) + suspend fun findStatusesForUser( myUser: User, onResult: (ImmutableList) -> Unit, @@ -1179,15 +1181,15 @@ class AccountViewModel( fun getChannelIfExists(hex: HexKey): Channel? = LocalCache.getChannelIfExists(hex) - fun loadParticipants( - participants: List, - onReady: (ImmutableList>) -> Unit, + fun loadParticipants( + participants: List, + onReady: (ImmutableList>) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { val participantUsers = participants .mapNotNull { part -> - checkGetOrCreateUser(part.key)?.let { + checkGetOrCreateUser(part.pubKey)?.let { Pair( part, it, @@ -1221,7 +1223,7 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.Default) { account.decryptPeopleList(event) { privateTagList -> onReady( - (event.taggedUsers() + event.filterUsers(privateTagList)) + (event.taggedUserIds() + event.filterUsers(privateTagList)) .toSet() .mapNotNull { hex -> checkGetOrCreateUser(hex) } .sortedBy { account.isFollowing(it) } @@ -1598,7 +1600,7 @@ class AccountViewModel( AdvertisedRelayListEvent.createAddressTag(user.pubkeyHex), ) - fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddressATag(account.signer.pubKey, dATag)) + fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag)) fun updateInteractiveStoryReadingState( root: InteractiveStoryBaseEvent, @@ -1664,6 +1666,8 @@ class AccountViewModel( } } + suspend fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account) + fun relayStatusFlow() = Amethyst.instance.client.relayStatusFlow() val draftNoteCache = CachedDraftNotes(this) @@ -1751,7 +1755,6 @@ fun mockAccountViewModel(): AccountViewModel { KeyPair( privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"), pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"), - forcePubKeyCheck = false, ), ), sharedPreferencesViewModel.sharedPrefs, @@ -1769,7 +1772,6 @@ fun mockVitorAccountViewModel(): AccountViewModel { keyPair = KeyPair( pubKey = Hex.decode("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - forcePubKeyCheck = false, ), ), sharedPreferencesViewModel.sharedPrefs, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt index a3d440fbae..796a5aa024 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt @@ -43,16 +43,16 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Nav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -134,11 +134,11 @@ fun redirect( event is LiveActivitiesChatMessageEvent || event is LiveActivitiesEvent ) { - (event as? ChannelMessageEvent)?.channel() - ?: (event as? ChannelMetadataEvent)?.channel() + (event as? ChannelMessageEvent)?.channelId() + ?: (event as? ChannelMetadataEvent)?.channelId() ?: (event as? ChannelCreateEvent)?.id ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() - ?: (event as? LiveActivitiesEvent)?.address()?.toTag() + ?: (event as? LiveActivitiesEvent)?.aTag()?.toTag() } else { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 9eac03605d..07f698f4c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -61,6 +61,7 @@ import androidx.compose.material.icons.filled.Sell import androidx.compose.material.icons.filled.ShowChart import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Assistant import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -146,6 +147,8 @@ import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview import com.vitorpamplona.amethyst.ui.components.LoadingAnimation +import com.vitorpamplona.amethyst.ui.components.SecretEmojiRequest +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.navigation.Nav @@ -162,7 +165,6 @@ import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.MyTextField import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -173,6 +175,7 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size5dp @@ -182,7 +185,7 @@ import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -193,6 +196,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.lang.Math.round @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @@ -325,8 +329,10 @@ fun NewPostScreen( CloseButton( onPress = { scope.launch { - postViewModel.sendDraftSync(relayList = relayList) - postViewModel.cancel() + withContext(Dispatchers.IO) { + postViewModel.sendDraftSync(relayList = relayList) + postViewModel.cancel() + } delay(100) nav.popBack() } @@ -521,7 +527,7 @@ fun NewPostScreen( it, accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent, mediaQuality -> - postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, false, server, accountViewModel::toast, context) if (server.type != ServerType.NIP95) { accountViewModel.account.settings.changeDefaultFileServer(server) } @@ -559,6 +565,20 @@ fun NewPostScreen( } } + if (postViewModel.wantsSecretEmoji) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + SecretEmojiRequest { + postViewModel.insertAtCursor(it) + postViewModel.wantsSecretEmoji = false + } + } + } + } + if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -638,10 +658,8 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { } } - if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { - AddLnInvoiceButton(postViewModel.wantsInvoice) { - postViewModel.wantsInvoice = !postViewModel.wantsInvoice - } + ForwardZapTo(postViewModel) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo } if (postViewModel.canAddZapRaiser) { @@ -658,8 +676,14 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash } - ForwardZapTo(postViewModel) { - postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + AddSecretEmoji(postViewModel.wantsSecretEmoji) { + postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji + } + + if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } } } } @@ -712,7 +736,7 @@ private fun MessageField(postViewModel: NewPostViewModel) { } } - MyTextField( + ThinPaddingTextField( value = postViewModel.message, onValueChange = { postViewModel.updateMessage(it) }, keyboardOptions = @@ -826,7 +850,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) { fontWeight = FontWeight.W500, ) - MyTextField( + ThinPaddingTextField( value = postViewModel.toUsers, onValueChange = { postViewModel.updateToUsers(it) }, modifier = Modifier.fillMaxWidth(), @@ -860,7 +884,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) { fontWeight = FontWeight.W500, ) - MyTextField( + ThinPaddingTextField( value = postViewModel.subject, onValueChange = { postViewModel.updateSubject(it) }, modifier = Modifier.fillMaxWidth(), @@ -901,7 +925,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { fontWeight = FontWeight.W500, ) - MyTextField( + ThinPaddingTextField( value = postViewModel.title, onValueChange = { postViewModel.updateTitle(it) @@ -937,7 +961,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { fontWeight = FontWeight.W500, ) - MyTextField( + ThinPaddingTextField( modifier = Modifier.fillMaxWidth(), value = postViewModel.price, onValueChange = { @@ -977,22 +1001,22 @@ fun SellProduct(postViewModel: NewPostViewModel) { val conditionTypes = listOf( Triple( - ClassifiedsEvent.CONDITION.NEW, + ConditionTag.CONDITION.NEW, stringRes(id = R.string.classifieds_condition_new), stringRes(id = R.string.classifieds_condition_new_explainer), ), Triple( - ClassifiedsEvent.CONDITION.USED_LIKE_NEW, + ConditionTag.CONDITION.USED_LIKE_NEW, stringRes(id = R.string.classifieds_condition_like_new), stringRes(id = R.string.classifieds_condition_like_new_explainer), ), Triple( - ClassifiedsEvent.CONDITION.USED_GOOD, + ConditionTag.CONDITION.USED_GOOD, stringRes(id = R.string.classifieds_condition_good), stringRes(id = R.string.classifieds_condition_good_explainer), ), Triple( - ClassifiedsEvent.CONDITION.USED_FAIR, + ConditionTag.CONDITION.USED_FAIR, stringRes(id = R.string.classifieds_condition_fair), stringRes(id = R.string.classifieds_condition_fair_explainer), ), @@ -1014,7 +1038,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { .weight(1f) .padding(end = 5.dp, bottom = 1.dp), ) { currentOption, modifier -> - MyTextField( + ThinPaddingTextField( value = TextFieldValue(currentOption), onValueChange = {}, readOnly = true, @@ -1080,7 +1104,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { .weight(1f) .padding(end = 5.dp, bottom = 1.dp), ) { currentOption, modifier -> - MyTextField( + ThinPaddingTextField( value = TextFieldValue(currentOption), onValueChange = {}, readOnly = true, @@ -1107,7 +1131,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { fontWeight = FontWeight.W500, ) - MyTextField( + ThinPaddingTextField( value = postViewModel.locationText, onValueChange = { postViewModel.updateLocation(it) @@ -1503,6 +1527,32 @@ private fun AddLnInvoiceButton( } } +@Composable +private fun AddSecretEmoji( + isSecretEmojiActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + if (!isSecretEmojiActive) { + Icon( + imageVector = Icons.Outlined.Assistant, + contentDescription = stringRes(id = R.string.secret_emoji_maker_explainer), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Outlined.Assistant, + contentDescription = stringRes(id = R.string.secret_emoji_maker_explainer), + modifier = Size20Modifier, + tint = BitcoinOrange, + ) + } + } +} + @Composable private fun ForwardZapTo( postViewModel: NewPostViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadModel.kt deleted file mode 100644 index 78e7a1f675..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadModel.kt +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms - -import android.content.Context -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.uploads.MediaCompressor -import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS -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.nip17Dm.AESGCM -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@Stable -open class ChatFileUploadModel : ViewModel() { - var account: Account? = null - var chatroom: ChatroomKey? = null - - var isUploadingImage by mutableStateOf(false) - - var selectedServer by mutableStateOf(null) - var caption by mutableStateOf("") - var sensitiveContent by mutableStateOf(false) - - // Images and Videos - var multiOrchestrator by mutableStateOf(null) - - // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED - var mediaQualitySlider by mutableIntStateOf(1) - - open fun load( - uris: ImmutableList, - chatroom: ChatroomKey, - account: Account, - ) { - this.chatroom = chatroom - this.caption = "" - this.account = account - this.multiOrchestrator = MultiOrchestrator(uris) - this.selectedServer = defaultServer() - } - - fun isImage( - url: String, - mimeType: String?, - ): Boolean = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url) - - fun upload( - onError: (title: String, message: String) -> Unit, - context: Context, - onceUploaded: () -> Unit, - ) { - val myAccount = account ?: return - val mySelectedServer = selectedServer ?: return - val myChatroom = chatroom ?: return - val myMultiOrchestrator = multiOrchestrator ?: return - - viewModelScope.launch(Dispatchers.Default) { - isUploadingImage = true - - val cipher = AESGCM() - - val results = - myMultiOrchestrator.uploadEncrypted( - viewModelScope, - caption, - sensitiveContent, - MediaCompressor.intToCompressorQuality(mediaQualitySlider), - cipher, - mySelectedServer, - myAccount, - context, - ) - - if (results.allGood) { - results.successful.forEach { state -> - if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - account?.sendNIP17EncryptedFile( - url = state.result.url, - toUsers = myChatroom.users.toList(), - replyingTo = null, - contentType = state.result.mimeTypeBeforeEncryption, - algo = cipher.name(), - key = cipher.keyBytes, - nonce = cipher.nonce, - originalHash = state.result.hashBeforeEncryption, - hash = state.result.fileHeader.hash, - size = state.result.fileHeader.size, - dimensions = state.result.fileHeader.dim, - blurhash = - state.result.fileHeader.blurHash - ?.blurhash, - alt = caption, - sensitiveContent = sensitiveContent, - ) - } - } - - onceUploaded() - cancelModel() - } else { - val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() - - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) - } - - isUploadingImage = false - } - } - - open fun cancelModel() { - multiOrchestrator = null - isUploadingImage = false - caption = "" - selectedServer = defaultServer() - } - - fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - multiOrchestrator?.remove(selected) - } - - fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null && selectedServer != null - - fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListScreen.kt deleted file mode 100644 index 785138e7be..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListScreen.kt +++ /dev/null @@ -1,499 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.DrawerState -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import com.google.accompanist.adaptive.FoldAwareConfiguration -import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy -import com.google.accompanist.adaptive.TwoPane -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource -import com.vitorpamplona.amethyst.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.MainTopBar -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.TabRowHeight -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@Composable -fun ChatroomListScreen( - accountViewModel: AccountViewModel, - nav: INav, -) { - val windowSizeClass by accountViewModel.settings.windowSizeClass - - val twoPane by remember { - derivedStateOf { - when (windowSizeClass?.widthSizeClass) { - WindowWidthSizeClass.Compact -> false - WindowWidthSizeClass.Expanded, - WindowWidthSizeClass.Medium, - -> true - else -> false - } - } - } - - if (twoPane && windowSizeClass != null) { - ChatroomListTwoPane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - widthSizeClass = windowSizeClass!!.widthSizeClass, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - ScaffoldChatroomListScreenOnlyList( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -class TwoPaneNav( - val nav: INav, -) : INav { - override val drawerState: DrawerState = nav.drawerState - - val innerNav = mutableStateOf(null) - - override fun nav(route: String) { - if (route.startsWith("Room/") || route.startsWith("Channel/")) { - innerNav.value = RouteId(route.substringBefore("/"), route.substringAfter("/")) - } else { - nav.nav(route) - } - } - - override fun newStack(route: String) { - nav.newStack(route) - } - - override fun popBack() { - nav.popBack() - } - - override fun popUpTo( - route: String, - upTo: String, - ) { - nav.popUpTo(route, upTo) - } - - override fun closeDrawer() { - nav.closeDrawer() - } - - override fun openDrawer() { - nav.openDrawer() - } - - data class RouteId( - val route: String, - val id: String, - ) -} - -@Composable -fun ChatroomListTwoPane( - knownFeedContentState: FeedContentState, - newFeedContentState: FeedContentState, - widthSizeClass: WindowWidthSizeClass, - accountViewModel: AccountViewModel, - nav: INav, -) { - /** The index of the currently selected word, or `null` if none is selected */ - val twoPaneNav = remember { TwoPaneNav(nav) } - - val strategy = - remember { - if (widthSizeClass == WindowWidthSizeClass.Expanded) { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 3f, - ) - } else { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 2.5f, - ) - } - } - - DisappearingScaffold( - isInvertedLayout = false, - topBar = { - Column { - MainTopBar(accountViewModel, nav) - } - }, - bottomBar = { - AppBottomBar(Route.Message, accountViewModel) { route, _ -> - nav.newStack(route.base) - } - }, - accountViewModel = accountViewModel, - ) { - TwoPane( - first = { - Box(Modifier.fillMaxSize().systemBarsPadding(), contentAlignment = Alignment.BottomEnd) { - ChatroomListScreenOnlyList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) - - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(accountViewModel, nav) - } - } - }, - second = { - Box(Modifier.fillMaxSize().systemBarsPadding()) { - twoPaneNav.innerNav.value?.let { - if (it.route == "Room") { - Chatroom( - roomId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (it.route == "Channel") { - Channel( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = accountViewModel.settings.displayFeatures.value, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.padding(it).consumeWindowInsets(it).fillMaxSize(), - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ChatroomListScreenOnlyList( - knownFeedContentState: FeedContentState, - newFeedContentState: FeedContentState, - accountViewModel: AccountViewModel, - nav: INav, -) { - val pagerState = rememberPagerState { 2 } - - val markKnownAsRead = remember { mutableStateOf(false) } - val markNewAsRead = remember { mutableStateOf(false) } - - WatchAccountForListScreen(knownFeedContentState, newFeedContentState, accountViewModel) - WatchLifecycleAndRefreshDataSource(accountViewModel) - - val tabs by - remember(knownFeedContentState, markKnownAsRead) { - derivedStateOf { - listOf( - ChatroomListTabItem(R.string.known, knownFeedContentState, markKnownAsRead), - ChatroomListTabItem(R.string.new_requests, newFeedContentState, markNewAsRead), - ) - } - } - - Column { - ChatroomListOnlyTabs( - pagerState, - tabs, - { markKnownAsRead.value = true }, - { markNewAsRead.value = true }, - ) - - ChatroomListTabs( - pagerState, - tabs, - PaddingValues(0.dp), - accountViewModel, - nav, - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ScaffoldChatroomListScreenOnlyList( - knownFeedContentState: FeedContentState, - newFeedContentState: FeedContentState, - accountViewModel: AccountViewModel, - nav: INav, -) { - val pagerState = rememberPagerState { 2 } - - val markKnownAsRead = remember { mutableStateOf(false) } - val markNewAsRead = remember { mutableStateOf(false) } - - WatchAccountForListScreen(knownFeedContentState, newFeedContentState, accountViewModel) - WatchLifecycleAndRefreshDataSource(accountViewModel) - - val tabs by - remember(knownFeedContentState, markKnownAsRead) { - derivedStateOf { - listOf( - ChatroomListTabItem(R.string.known, knownFeedContentState, markKnownAsRead), - ChatroomListTabItem(R.string.new_requests, newFeedContentState, markNewAsRead), - ) - } - } - - DisappearingScaffold( - isInvertedLayout = false, - topBar = { - Column { - MainTopBar(accountViewModel, nav) - ChatroomListOnlyTabs( - pagerState, - tabs, - { markKnownAsRead.value = true }, - { markNewAsRead.value = true }, - ) - } - }, - bottomBar = { - AppBottomBar(Route.Message, accountViewModel) { route, _ -> - if (route == Route.Message) { - tabs[pagerState.currentPage].feedContentState.sendToTop() - } else { - nav.newStack(route.base) - } - } - }, - floatingButton = { - ChannelFabColumn(accountViewModel, nav) - }, - accountViewModel = accountViewModel, - ) { - ChatroomListTabs( - pagerState, - tabs, - it, - accountViewModel, - nav, - ) - } -} - -@Composable -private fun WatchLifecycleAndRefreshDataSource(accountViewModel: AccountViewModel) { - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrChatroomListDataSource.account = accountViewModel.account - NostrChatroomListDataSource.start() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun ChatroomListTabs( - pagerState: PagerState, - tabs: List, - paddingValues: PaddingValues, - accountViewModel: AccountViewModel, - nav: INav, -) { - HorizontalPager( - contentPadding = paddingValues, - state = pagerState, - userScrollEnabled = false, - ) { page -> - ChatroomListFeedView( - feedContentState = tabs[page].feedContentState, - accountViewModel = accountViewModel, - nav = nav, - markAsRead = tabs[page].markAsRead, - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ChatroomListOnlyTabs( - pagerState: PagerState, - tabs: List, - onMarkKnownAsRead: () -> Unit, - onMarkNewAsRead: () -> Unit, -) { - var moreActionsExpanded by remember { mutableStateOf(false) } - val coroutineScope = rememberCoroutineScope() - - Box(Modifier.fillMaxWidth()) { - TabRow( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - ) { - tabs.forEachIndexed { index, tab -> - Tab( - selected = pagerState.currentPage == index, - text = { Text(text = stringRes(tab.resource)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, - ) - } - } - - IconButton( - modifier = - Modifier - .size(40.dp) - .align(Alignment.CenterEnd), - onClick = { moreActionsExpanded = true }, - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringRes(id = R.string.more_options), - tint = MaterialTheme.colorScheme.placeholderText, - ) - - ChatroomTabMenu( - moreActionsExpanded, - { moreActionsExpanded = false }, - onMarkKnownAsRead, - onMarkNewAsRead, - ) - } - } -} - -@Composable -fun WatchAccountForListScreen( - knownFeedContentState: FeedContentState, - newFeedContentState: FeedContentState, - accountViewModel: AccountViewModel, -) { - LaunchedEffect(accountViewModel) { - launch(Dispatchers.IO) { - NostrChatroomListDataSource.account = accountViewModel.account - NostrChatroomListDataSource.start() - knownFeedContentState.invalidateData(true) - newFeedContentState.invalidateData(true) - } - } -} - -@Immutable -class ChatroomListTabItem( - val resource: Int, - val feedContentState: FeedContentState, - val markAsRead: MutableState, -) - -@Composable -fun ChatroomTabMenu( - expanded: Boolean, - onDismiss: () -> Unit, - onMarkKnownAsRead: () -> Unit, - onMarkNewAsRead: () -> Unit, -) { - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - DropdownMenuItem( - text = { Text(stringRes(R.string.mark_all_known_as_read)) }, - onClick = { - onMarkKnownAsRead() - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.mark_all_new_as_read)) }, - onClick = { - onMarkNewAsRead() - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.mark_all_as_read)) }, - onClick = { - onMarkKnownAsRead() - onMarkNewAsRead() - onDismiss() - }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt deleted file mode 100644 index e1f2019d9b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt +++ /dev/null @@ -1,1000 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.EditNote -import androidx.compose.material.icons.filled.Send -import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrChatroomDataSource -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.IncognitoIconOff -import com.vitorpamplona.amethyst.ui.note.IncognitoIconOn -import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures -import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog -import com.vitorpamplona.amethyst.ui.note.ShowEmojiSuggestionList -import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList -import com.vitorpamplona.amethyst.ui.note.UserCompose -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs -import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound -import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder -import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier -import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.Size30Modifier -import com.vitorpamplona.amethyst.ui.theme.Size34dp -import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.ZeroPadding -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.NIP17Group -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch - -@Composable -fun ChatroomScreen( - roomId: String?, - draftMessage: String? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (roomId == null) return - - DisappearingScaffold( - isInvertedLayout = true, - topBar = { - RoomTopBar(roomId, accountViewModel, nav) - }, - accountViewModel = accountViewModel, - ) { - Column(Modifier.padding(it)) { - Chatroom(roomId, draftMessage, 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 -private fun RenderRoomTopBar( - room: ChatroomKey, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (room.users.size == 1) { - TopBarExtensibleWithBackButton( - title = { - LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> - if (baseUser != null) { - ClickableUserPicture( - baseUser = baseUser, - accountViewModel = accountViewModel, - size = Size34dp, - ) - - Spacer(modifier = DoubleHorzSpacer) - - UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) - } - } - }, - extendableRow = { - LoadUser(baseUserHex = room.users.first(), accountViewModel) { - if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - }, - popBack = nav::popBack, - ) - } else { - TopBarExtensibleWithBackButton( - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - NonClickableUserPictures( - room = room, - accountViewModel = accountViewModel, - size = Size34dp, - ) - - RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel) - } - }, - extendableRow = { - LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav) - }, - popBack = nav::popBack, - ) - } -} - -@Composable -fun Chatroom( - roomId: String?, - draftMessage: String? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (roomId == null) return - - LoadRoom(roomId, accountViewModel) { - it?.let { - PrepareChatroomViewModels( - room = it, - draftMessage = draftMessage, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} - -@Composable -fun ChatroomScreenByAuthor( - authorPubKeyHex: String?, - draftMessage: String? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (authorPubKeyHex == null) return - - DisappearingScaffold( - isInvertedLayout = true, - topBar = { - RoomByAuthorTopBar(authorPubKeyHex, accountViewModel, nav) - }, - accountViewModel = accountViewModel, - ) { - Column(Modifier.padding(it)) { - ChatroomByAuthor(authorPubKeyHex, draftMessage, accountViewModel, nav) - } - } -} - -@Composable -private fun RoomByAuthorTopBar( - authorPubKeyHex: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadRoomByAuthor(authorPubKeyHex = authorPubKeyHex, accountViewModel) { room -> - if (room != null) { - RenderRoomTopBar(room, accountViewModel, nav) - } else { - Spacer(BottomTopHeight) - } - } -} - -@Composable -fun ChatroomByAuthor( - authorPubKeyHex: String?, - draftMessage: String? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (authorPubKeyHex == null) return - - LoadRoomByAuthor(authorPubKeyHex, accountViewModel) { - it?.let { - PrepareChatroomViewModels( - room = it, - draftMessage = draftMessage, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} - -@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) -} - -@Composable -fun LoadRoomByAuthor( - authorPubKeyHex: String, - accountViewModel: AccountViewModel, - content: @Composable (ChatroomKey?) -> Unit, -) { - val room by - remember(authorPubKeyHex) { - mutableStateOf(ChatroomKey(persistentSetOf(authorPubKeyHex))) - } - - content(room) -} - -@Composable -fun PrepareChatroomViewModels( - room: ChatroomKey, - draftMessage: String?, - accountViewModel: AccountViewModel, - nav: INav, -) { - val feedViewModel: NostrChatroomFeedViewModel = - viewModel( - key = room.hashCode().toString() + "ChatroomViewModels", - factory = - NostrChatroomFeedViewModel.Factory( - room, - accountViewModel.account, - ), - ) - - val newPostModel: NewPostViewModel = viewModel() - newPostModel.accountViewModel = accountViewModel - newPostModel.account = accountViewModel.account - newPostModel.requiresNIP17 = room.users.size > 1 - - if (newPostModel.requiresNIP17) { - newPostModel.nip17 = true - } else { - if (room.users.size == 1) { - ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) { - if (it?.relays().isNullOrEmpty()) { - newPostModel.nip17 = false - } else { - newPostModel.nip17 = true - } - } - } - } - - val imageUpload: ChatFileUploadModel = viewModel() - - if (draftMessage != null) { - LaunchedEffect(key1 = draftMessage) { newPostModel.updateMessage(TextFieldValue(draftMessage)) } - } - - ChatroomScreen( - room = room, - feedViewModel = feedViewModel, - newPostModel = newPostModel, - fileUpload = imageUpload, - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -fun ChatroomScreen( - room: ChatroomKey, - feedViewModel: NostrChatroomFeedViewModel, - newPostModel: NewPostViewModel, - fileUpload: ChatFileUploadModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) - - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(room, accountViewModel) { - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) - NostrChatroomDataSource.start() - feedViewModel.invalidateData() - - onDispose { NostrChatroomDataSource.stop() } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Private Message Start") - NostrChatroomDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Private Message Stop") - NostrChatroomDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - Column(Modifier.fillMaxHeight()) { - val replyTo = remember { mutableStateOf(null) } - ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) - - Column( - modifier = - Modifier - .fillMaxHeight() - .padding(vertical = 0.dp) - .weight(1f, true), - ) { - RefreshingChatroomFeedView( - viewModel = feedViewModel, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = "Room/${room.hashCode()}", - avoidDraft = newPostModel.draftTag, - onWantsToReply = { - replyTo.value = it - }, - onWantsToEditDraft = { - newPostModel.load(accountViewModel, null, null, null, null, it) - }, - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } - - val scope = rememberCoroutineScope() - - LaunchedEffect(key1 = newPostModel.draftTag) { - launch(Dispatchers.IO) { - newPostModel.draftTextChanges - .receiveAsFlow() - .debounce(1000) - .collectLatest { - innerSendPost(newPostModel, room, replyTo, accountViewModel, newPostModel.draftTag) - } - } - } - - fileUpload.multiOrchestrator?.let { - ChatFileUploadView( - fileUpload, - onClose = fileUpload::cancelModel, - accountViewModel, - nav, - ) - } - - // LAST ROW - PrivateMessageEditFieldRow( - newPostModel, - accountViewModel, - onSendNewMessage = { - scope.launch(Dispatchers.IO) { - innerSendPost(newPostModel, room, replyTo, accountViewModel, null) - - accountViewModel.deleteDraft(newPostModel.draftTag) - - newPostModel.message = TextFieldValue("") - - replyTo.value = null - feedViewModel.sendToTop() - } - }, - onSendNewMedia = { - fileUpload.load(it, room, accountViewModel.account) - }, - ) - } -} - -private fun innerSendPost( - newPostModel: NewPostViewModel, - room: ChatroomKey, - replyTo: MutableState, - accountViewModel: AccountViewModel, - dTag: String?, -) { - val urls = findURLs(newPostModel.message.text) - val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() } - val emojis = newPostModel.findEmoji(newPostModel.message.text, accountViewModel.account.myEmojis.value) - - if (newPostModel.nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) { - accountViewModel.account.sendNIP17PrivateMessage( - message = newPostModel.message.text, - toUsers = room.users.toList(), - replyingTo = replyTo.value, - mentions = null, - wantsToMarkAsSensitive = false, - imetas = usedAttachments, - emojis = emojis, - draftTag = dTag, - ) - } else { - accountViewModel.account.sendPrivateMessage( - message = newPostModel.message.text, - toUser = room.users.first(), - replyingTo = replyTo.value, - mentions = null, - wantsToMarkAsSensitive = false, - imetas = usedAttachments, - draftTag = dTag, - ) - } -} - -@Composable -fun PrivateMessageEditFieldRow( - channelScreenModel: NewPostViewModel, - accountViewModel: AccountViewModel, - onSendNewMessage: () -> Unit, - onSendNewMedia: (ImmutableList) -> Unit, -) { - Column( - modifier = EditFieldModifier, - ) { - ShowUserSuggestionList( - channelScreenModel.userSuggestions, - channelScreenModel::autocompleteWithUser, - accountViewModel, - ) - - ShowEmojiSuggestionList( - channelScreenModel.emojiSuggestions, - channelScreenModel::autocompleteWithEmoji, - channelScreenModel::autocompleteWithEmojiUrl, - accountViewModel, - ) - - MyTextField( - value = channelScreenModel.message, - onValueChange = { channelScreenModel.updateMessage(it) }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - shape = EditFieldBorder, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringRes(R.string.reply_here), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - ThinSendButton( - isActive = - channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, - modifier = EditFieldTrailingIconModifier, - ) { - onSendNewMessage() - } - }, - leadingIcon = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 6.dp), - ) { - SelectFromGallery( - isUploading = channelScreenModel.isUploadingImage, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = - Modifier - .size(30.dp) - .padding(start = 2.dp), - onImageChosen = onSendNewMedia, - ) - - var wantsToActivateNIP17 by remember { mutableStateOf(false) } - - if (wantsToActivateNIP17) { - NewFeatureNIP17AlertDialog( - accountViewModel = accountViewModel, - onConfirm = { channelScreenModel.toggleNIP04And24() }, - onDismiss = { wantsToActivateNIP17 = false }, - ) - } - - IconButton( - modifier = Size30Modifier, - onClick = { - if ( - !accountViewModel.account.settings.hideNIP17WarningDialog && - !channelScreenModel.nip17 && - !channelScreenModel.requiresNIP17 - ) { - wantsToActivateNIP17 = true - } else { - channelScreenModel.toggleNIP04And24() - } - }, - ) { - if (channelScreenModel.nip17) { - IncognitoIconOn( - modifier = - Modifier - .padding(top = 2.dp) - .size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } else { - IncognitoIconOff( - modifier = - Modifier - .padding(top = 2.dp) - .size(18.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - } - } - } - }, - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), - ) - } -} - -@Composable -fun NewFeatureNIP17AlertDialog( - accountViewModel: AccountViewModel, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - val scope = rememberCoroutineScope() - - QuickActionAlertDialog( - title = stringRes(R.string.new_feature_nip17_might_not_be_available_title), - textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description), - buttonIconResource = R.drawable.incognito, - buttonText = stringRes(R.string.new_feature_nip17_activate), - onClickDoOnce = { - scope.launch { onConfirm() } - onDismiss() - }, - onClickDontShowAgain = { - scope.launch { - onConfirm() - accountViewModel.account.settings.setHideNIP17WarningDialog() - } - onDismiss() - }, - onDismiss = onDismiss, - ) -} - -@Composable -fun ThinSendButton( - isActive: Boolean, - modifier: Modifier, - onClick: () -> Unit, -) { - IconButton( - enabled = isActive, - modifier = modifier, - onClick = onClick, - ) { - Icon( - imageVector = Icons.Default.Send, - contentDescription = stringRes(id = R.string.accessibility_send), - modifier = Size20Modifier, - ) - } -} - -@Composable -fun ChatroomHeader( - room: ChatroomKey, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - onClick: () -> Unit, -) { - if (room.users.size == 1) { - LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> - if (baseUser != null) { - ChatroomHeader( - baseUser = baseUser, - modifier = modifier, - accountViewModel = accountViewModel, - onClick = onClick, - ) - } - } - } else { - GroupChatroomHeader( - room = room, - modifier = modifier, - accountViewModel = accountViewModel, - onClick = onClick, - ) - } -} - -@Composable -fun ChatroomHeader( - baseUser: User, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - onClick: () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .clickable( - onClick = onClick, - ), - ) { - Column( - verticalArrangement = Arrangement.Center, - modifier = modifier, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - ClickableUserPicture( - baseUser = baseUser, - accountViewModel = accountViewModel, - size = Size34dp, - ) - - Column(modifier = Modifier.padding(start = 10.dp)) { - UsernameDisplay(baseUser, accountViewModel = accountViewModel) - } - } - } - } -} - -@Composable -fun GroupChatroomHeader( - room: ChatroomKey, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - onClick: () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - ) { - Column( - verticalArrangement = Arrangement.Center, - modifier = modifier, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - NonClickableUserPictures( - room = room, - accountViewModel = accountViewModel, - size = Size34dp, - ) - - RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Bold, accountViewModel) - } - } - } -} - -@Composable -private fun EditRoomSubjectButton( - room: ChatroomKey, - accountViewModel: AccountViewModel, -) { - var wantsToPost by remember { mutableStateOf(false) } - - if (wantsToPost) { - NewSubjectView({ wantsToPost = false }, accountViewModel, room) - } - - Button( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { wantsToPost = true }, - contentPadding = ZeroPadding, - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.EditNote, - contentDescription = stringRes(R.string.edits_the_channel_metadata), - ) - } -} - -@Composable -fun NewSubjectView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - room: ChatroomKey, -) { - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - dismissOnClickOutside = false, - ), - ) { - Surface { - val groupName = - remember { - mutableStateOf(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "") - } - val message = remember { mutableStateOf("") } - val scope = rememberCoroutineScope() - - Column( - modifier = - Modifier - .padding(10.dp) - .verticalScroll(rememberScrollState()), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = { onClose() }) - - PostButton( - onPost = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.sendNIP17PrivateMessage( - message = message.value, - toUsers = room.users.toList(), - subject = groupName.value.ifBlank { null }, - replyingTo = null, - mentions = null, - wantsToMarkAsSensitive = false, - ) - } - - onClose() - }, - true, - ) - } - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(R.string.messages_new_message_subject)) }, - modifier = Modifier.fillMaxWidth(), - value = groupName.value, - onValueChange = { groupName.value = it }, - placeholder = { - Text( - text = stringRes(R.string.messages_new_message_subject_caption), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(R.string.messages_new_subject_message)) }, - modifier = - Modifier - .fillMaxWidth() - .height(100.dp), - value = message.value, - onValueChange = { message.value = it }, - placeholder = { - Text( - text = stringRes(R.string.messages_new_subject_message_placeholder), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 10, - ) - } - } - } -} - -@Composable -fun LongRoomHeader( - room: ChatroomKey, - lineModifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: INav, -) { - val list = remember(room) { room.users.toPersistentList() } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(id = R.string.messages_group_descriptor), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - ) - - EditRoomSubjectButton(room, accountViewModel) - } - - LazyColumn( - modifier = Modifier, - state = rememberLazyListState(), - ) { - itemsIndexed(list, key = { _, item -> item }) { _, item -> - LoadUser(baseUserHex = item, accountViewModel) { - if (it != null) { - UserCompose( - baseUser = it, - overallModifier = lineModifier, - accountViewModel = accountViewModel, - nav = nav, - ) - HorizontalDivider( - thickness = DividerThickness, - ) - } - } - } - } -} - -@Composable -fun RoomNameOnlyDisplay( - room: ChatroomKey, - modifier: Modifier, - fontWeight: FontWeight = FontWeight.Bold, - accountViewModel: AccountViewModel, -) { - val roomSubject by - accountViewModel - .userProfile() - .live() - .messages - .map { it.user.privateChatrooms[room]?.subject } - .distinctUntilChanged() - .observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) - - CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) { - if (!it.isNullOrBlank()) { - DisplayRoomSubject(it, fontWeight) - } else { - DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChannelFabColumn.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelFabColumn.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChannelFabColumn.kt index 06e9802d8f..09d17a288a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChannelFabColumn.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChatroomHeaderCompose.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomHeaderCompose.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChatroomHeaderCompose.kt index 7120cff011..4e10282734 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChatroomHeaderCompose.kt @@ -18,11 +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.amethyst.ui.screen.loggedIn.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -54,16 +53,12 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map -import com.patrykandpatrick.vico.core.extension.forEachIndexedExtended import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout import com.vitorpamplona.amethyst.ui.navigation.INav @@ -72,19 +67,19 @@ import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent @Composable @@ -303,131 +298,6 @@ private fun UserRoomCompose( ) } -@Composable -fun RoomNameDisplay( - room: ChatroomKey, - modifier: Modifier, - accountViewModel: AccountViewModel, -) { - val roomSubject by - accountViewModel - .userProfile() - .live() - .messages - .map { it.user.privateChatrooms[room]?.subject } - .distinctUntilChanged() - .observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) - - CrossfadeIfEnabled(targetState = roomSubject, modifier, label = "RoomNameDisplay", accountViewModel = accountViewModel) { - if (!it.isNullOrBlank()) { - if (room.users.size > 1) { - DisplayRoomSubject(it) - } else { - DisplayUserAndSubject(room.users.first(), it, accountViewModel) - } - } else { - DisplayUserSetAsSubject(room, accountViewModel) - } - } -} - -@Composable -private fun DisplayUserAndSubject( - user: HexKey, - subject: String, - accountViewModel: AccountViewModel, -) { - Row { - Text( - text = subject, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = " - ", - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - LoadUser(baseUserHex = user, accountViewModel = accountViewModel) { - it?.let { UsernameDisplay(it, Modifier.weight(1f), accountViewModel = accountViewModel) } - } - } -} - -@Composable -fun DisplayUserSetAsSubject( - room: ChatroomKey, - accountViewModel: AccountViewModel, - fontWeight: FontWeight = FontWeight.Bold, -) { - val userList = remember(room) { room.users.toList() } - - if (userList.size == 1) { - // Regular Design - Row { - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight, accountViewModel = accountViewModel) } - } - } - } else { - Row { - userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value -> - LoadUser(baseUserHex = value, accountViewModel) { - it?.let { ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight, accountViewModel = accountViewModel) } - } - - if (!isLast) { - Text( - text = ", ", - fontWeight = fontWeight, - maxLines = 1, - ) - } - } - } - } -} - -@Composable -fun DisplayRoomSubject( - roomSubject: String, - fontWeight: FontWeight = FontWeight.Bold, -) { - Row { - Text( - text = roomSubject, - fontWeight = fontWeight, - maxLines = 1, - ) - } -} - -@Composable -fun ShortUsernameDisplay( - baseUser: User, - weight: Modifier = Modifier, - fontWeight: FontWeight = FontWeight.Bold, - accountViewModel: AccountViewModel, -) { - val userName by - baseUser - .live() - .metadata - .map { it.user.toBestShortFirstName() } - .distinctUntilChanged() - .observeAsState(baseUser.toBestShortFirstName()) - - CrossfadeIfEnabled(targetState = userName, modifier = weight, accountViewModel = accountViewModel) { - CreateTextWithEmoji( - text = it, - tags = baseUser.info?.tags, - fontWeight = fontWeight, - maxLines = 1, - ) - } -} - @Composable fun LoadUser( baseUserHex: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/MessagesScreen.kt new file mode 100644 index 0000000000..ff75c27555 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/MessagesScreen.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list + +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.singlepane.MessagesSinglePane +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.twopane.MessagesTwoPane + +@Composable +fun MessagesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val windowSizeClass by accountViewModel.settings.windowSizeClass + + val twoPane by remember { + derivedStateOf { + when (windowSizeClass?.widthSizeClass) { + WindowWidthSizeClass.Compact -> false + WindowWidthSizeClass.Expanded, + WindowWidthSizeClass.Medium, + -> true + else -> false + } + } + } + + if (twoPane && windowSizeClass != null) { + MessagesTwoPane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + widthSizeClass = windowSizeClass!!.widthSizeClass, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + MessagesSinglePane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/WatchAccountForListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/WatchAccountForListScreen.kt new file mode 100644 index 0000000000..21ef62ac16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/WatchAccountForListScreen.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource +import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun WatchAccountForListScreen( + knownFeedContentState: FeedContentState, + newFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + LaunchedEffect(accountViewModel) { + launch(Dispatchers.IO) { + NostrChatroomListDataSource.account = accountViewModel.account + NostrChatroomListDataSource.start() + knownFeedContentState.invalidateData(true) + newFeedContentState.invalidateData(true) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/WatchLifecycleAndRefreshDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/WatchLifecycleAndRefreshDataSource.kt new file mode 100644 index 0000000000..b360a8f9a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/WatchLifecycleAndRefreshDataSource.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun WatchLifecycleAndRefreshDataSource(accountViewModel: AccountViewModel) { + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + NostrChatroomListDataSource.account = accountViewModel.account + NostrChatroomListDataSource.start() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/feed/ChatroomListFeedView.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListFeedView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/feed/ChatroomListFeedView.kt index 5ee523f79f..00a0a7d666 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/feed/ChatroomListFeedView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.feed import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Row @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -52,7 +53,9 @@ fun ChatroomListFeedView( nav: INav, markAsRead: MutableState, ) { - RefresheableBox(feedContentState, true) { CrossFadeState(feedContentState, accountViewModel, nav, markAsRead) } + RefresheableBox(feedContentState, true) { + CrossFadeState(feedContentState, accountViewModel, nav, markAsRead) + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/feed/ChatroomListTabs.kt new file mode 100644 index 0000000000..86b36d8b36 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/feed/ChatroomListTabs.kt @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.feed + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.launch + +@Immutable +class MessagesTabItem( + val resource: Int, + val feedContentState: FeedContentState, + val markAsRead: MutableState, +) + +@Composable +fun MessagesTabHeader( + pagerState: PagerState, + tabs: List, + onMarkKnownAsRead: () -> Unit, + onMarkNewAsRead: () -> Unit, +) { + var moreActionsExpanded by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + Box(Modifier.fillMaxWidth()) { + TabRow( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { Text(text = stringRes(tab.resource)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + ) + } + } + + IconButton( + modifier = + Modifier + .size(40.dp) + .align(Alignment.CenterEnd), + onClick = { moreActionsExpanded = true }, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringRes(id = R.string.more_options), + tint = MaterialTheme.colorScheme.placeholderText, + ) + + MessagesTabMenu( + moreActionsExpanded, + { moreActionsExpanded = false }, + onMarkKnownAsRead, + onMarkNewAsRead, + ) + } + } +} + +@Composable +fun MessagesPager( + pagerState: PagerState, + tabs: List, + paddingValues: PaddingValues, + accountViewModel: AccountViewModel, + nav: INav, +) { + HorizontalPager( + contentPadding = paddingValues, + state = pagerState, + userScrollEnabled = false, + ) { page -> + ChatroomListFeedView( + feedContentState = tabs[page].feedContentState, + accountViewModel = accountViewModel, + nav = nav, + markAsRead = tabs[page].markAsRead, + ) + } +} + +@Composable +fun MessagesTabMenu( + expanded: Boolean, + onDismiss: () -> Unit, + onMarkKnownAsRead: () -> Unit, + onMarkNewAsRead: () -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem( + text = { Text(stringRes(R.string.mark_all_known_as_read)) }, + onClick = { + onMarkKnownAsRead() + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.mark_all_new_as_read)) }, + onClick = { + onMarkNewAsRead() + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.mark_all_as_read)) }, + onClick = { + onMarkKnownAsRead() + onMarkNewAsRead() + onDismiss() + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/singlepane/MessagesSinglePane.kt new file mode 100644 index 0000000000..eaf27c6ffb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/singlepane/MessagesSinglePane.kt @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.singlepane + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.MainTopBar +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.ChannelFabColumn +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.WatchAccountForListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.WatchLifecycleAndRefreshDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.feed.MessagesPager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.feed.MessagesTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.feed.MessagesTabItem + +@Composable +fun MessagesSinglePane( + knownFeedContentState: FeedContentState, + newFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val pagerState = rememberPagerState { 2 } + + val markKnownAsRead = remember { mutableStateOf(false) } + val markNewAsRead = remember { mutableStateOf(false) } + + WatchAccountForListScreen(knownFeedContentState, newFeedContentState, accountViewModel) + WatchLifecycleAndRefreshDataSource(accountViewModel) + + val tabs by + remember(knownFeedContentState, markKnownAsRead) { + derivedStateOf { + listOf( + MessagesTabItem(R.string.known, knownFeedContentState, markKnownAsRead), + MessagesTabItem(R.string.new_requests, newFeedContentState, markNewAsRead), + ) + } + } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + Column { + MainTopBar(accountViewModel, nav) + MessagesTabHeader( + pagerState, + tabs, + { markKnownAsRead.value = true }, + { markNewAsRead.value = true }, + ) + } + }, + bottomBar = { + AppBottomBar(Route.Message, accountViewModel) { route, _ -> + if (route == Route.Message) { + tabs[pagerState.currentPage].feedContentState.sendToTop() + } else { + nav.newStack(route.base) + } + } + }, + floatingButton = { + ChannelFabColumn(accountViewModel, nav) + }, + accountViewModel = accountViewModel, + ) { + MessagesPager( + pagerState, + tabs, + it, + accountViewModel, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/ChatroomListPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/ChatroomListPane.kt new file mode 100644 index 0000000000..212f019f7c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/ChatroomListPane.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.twopane + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.WatchAccountForListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.WatchLifecycleAndRefreshDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.feed.MessagesPager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.feed.MessagesTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.feed.MessagesTabItem + +@Composable +fun ChatroomList( + knownFeedContentState: FeedContentState, + newFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val pagerState = rememberPagerState { 2 } + + val markKnownAsRead = remember { mutableStateOf(false) } + val markNewAsRead = remember { mutableStateOf(false) } + + WatchAccountForListScreen(knownFeedContentState, newFeedContentState, accountViewModel) + WatchLifecycleAndRefreshDataSource(accountViewModel) + + val tabs by + remember(knownFeedContentState, markKnownAsRead) { + derivedStateOf { + listOf( + MessagesTabItem(R.string.known, knownFeedContentState, markKnownAsRead), + MessagesTabItem(R.string.new_requests, newFeedContentState, markNewAsRead), + ) + } + } + + Column { + MessagesTabHeader( + pagerState, + tabs, + { markKnownAsRead.value = true }, + { markNewAsRead.value = true }, + ) + + MessagesPager( + pagerState, + tabs, + PaddingValues(0.dp), + accountViewModel, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/MessagesTwoPane.kt new file mode 100644 index 0000000000..053738bfd3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/MessagesTwoPane.kt @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.twopane + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.google.accompanist.adaptive.FoldAwareConfiguration +import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy +import com.google.accompanist.adaptive.TwoPane +import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.MainTopBar +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.ChannelFabColumn +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.Chatroom +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.Channel +import com.vitorpamplona.amethyst.ui.theme.Size20dp + +@Composable +fun MessagesTwoPane( + knownFeedContentState: FeedContentState, + newFeedContentState: FeedContentState, + widthSizeClass: WindowWidthSizeClass, + accountViewModel: AccountViewModel, + nav: INav, +) { + /** The index of the currently selected word, or `null` if none is selected */ + val scope = rememberCoroutineScope() + val twoPaneNav = remember { TwoPaneNav(nav, scope) } + + val strategy = + remember { + if (widthSizeClass == WindowWidthSizeClass.Expanded) { + HorizontalTwoPaneStrategy( + splitFraction = 1f / 3f, + ) + } else { + HorizontalTwoPaneStrategy( + splitFraction = 1f / 2.5f, + ) + } + } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + Column { + MainTopBar(accountViewModel, nav) + } + }, + bottomBar = { + AppBottomBar(Route.Message, accountViewModel) { route, _ -> + nav.newStack(route.base) + } + }, + accountViewModel = accountViewModel, + ) { padding -> + TwoPane( + first = { + Box(Modifier.fillMaxSize().systemBarsPadding(), contentAlignment = Alignment.BottomEnd) { + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) + + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(accountViewModel, nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().systemBarsPadding()) { + twoPaneNav.innerNav.value?.let { + if (it.route == "Room") { + Chatroom( + roomId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (it.route == "Channel") { + Channel( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + }, + strategy = strategy, + displayFeatures = accountViewModel.settings.displayFeatures.value, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.padding(padding).consumeWindowInsets(padding).fillMaxSize(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/TwoPaneNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/TwoPaneNav.kt new file mode 100644 index 0000000000..d4643b076f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/TwoPaneNav.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.twopane + +import androidx.compose.material3.DrawerState +import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.amethyst.ui.navigation.INav +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class TwoPaneNav( + val nav: INav, + val scope: CoroutineScope, +) : INav { + override val drawerState: DrawerState = nav.drawerState + + val innerNav = mutableStateOf(null) + + override fun nav(route: String) { + if (route.startsWith("Room/") || route.startsWith("Channel/")) { + innerNav.value = RouteId(route.substringBefore("/"), route.substringAfter("/")) + } else { + nav.nav(route) + } + } + + override fun nav(routeMaker: suspend () -> String) { + scope.launch(Dispatchers.Default) { + val route = routeMaker() + if (route.startsWith("Room/") || route.startsWith("Channel/")) { + innerNav.value = RouteId(route.substringBefore("/"), route.substringAfter("/")) + } else { + nav.nav(route) + } + } + } + + override fun newStack(route: String) { + nav.newStack(route) + } + + override fun popBack() { + nav.popBack() + } + + override fun popUpTo( + route: String, + upTo: String, + ) { + nav.popUpTo(route, upTo) + } + + override fun closeDrawer() { + nav.closeDrawer() + } + + override fun openDrawer() { + nav.openDrawer() + } + + data class RouteId( + val route: String, + val id: String, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt new file mode 100644 index 0000000000..c189b9d519 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar +import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.collections.immutable.persistentSetOf + +@Composable +fun ChatroomByAuthorScreen( + authorPubKeyHex: String?, + draftMessage: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (authorPubKeyHex == null) return + + DisappearingScaffold( + isInvertedLayout = true, + topBar = { + RoomByAuthorTopBar(authorPubKeyHex, accountViewModel, nav) + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it)) { + ChatroomByAuthor(authorPubKeyHex, draftMessage, accountViewModel, nav) + } + } +} + +@Composable +fun LoadRoomByAuthor( + authorPubKeyHex: String, + accountViewModel: AccountViewModel, + content: @Composable (ChatroomKey?) -> Unit, +) { + val room by remember(authorPubKeyHex) { + mutableStateOf(ChatroomKey(persistentSetOf(authorPubKeyHex))) + } + + content(room) +} + +@Composable +private fun RoomByAuthorTopBar( + authorPubKeyHex: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadRoomByAuthor(authorPubKeyHex = authorPubKeyHex, accountViewModel) { room -> + if (room != null) { + RenderRoomTopBar(room, accountViewModel, nav) + } else { + Spacer(BottomTopHeight) + } + } +} + +@Composable +fun ChatroomByAuthor( + authorPubKeyHex: String?, + draftMessage: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (authorPubKeyHex == null) return + + LoadRoomByAuthor(authorPubKeyHex, accountViewModel) { + it?.let { + ChatroomView( + room = it, + draftMessage = draftMessage, + replyToNote = null, + editFromDraft = null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} 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 new file mode 100644 index 0000000000..7b6e3173fb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.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.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +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?, + 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) + }, + 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, + draftMessage = draftMessage, + replyToNote = replyToNote, + editFromDraft = editFromDraft, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun LoadRoom( + roomId: String, + accountViewModel: AccountViewModel, + content: @Composable (ChatroomKey?) -> Unit, +) { + var room by remember(roomId) { mutableStateOf(null) } + + if (room == null) { + LaunchedEffect(key1 = roomId) { + launch(Dispatchers.IO) { + val newRoom = + accountViewModel.userProfile().privateChatrooms.keys.firstOrNull { + it.hashCode().toString() == roomId + } + if (room != newRoom) { + room = newRoom + } + } + } + } + + content(room) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt new file mode 100644 index 0000000000..3a38db622a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.service.NostrChatroomDataSource +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs +import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound +import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.launch + +@Composable +fun ChatroomView( + room: ChatroomKey, + draftMessage: String?, + replyToNote: HexKey? = null, + editFromDraft: HexKey? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: NostrChatroomFeedViewModel = + viewModel( + key = room.hashCode().toString() + "ChatroomViewModels", + factory = + NostrChatroomFeedViewModel.Factory( + room, + accountViewModel.account, + ), + ) + + val newPostModel: ChatNewMessageViewModel = viewModel() + newPostModel.init(accountViewModel) + newPostModel.load(room) + + if (replyToNote != null) { + LaunchedEffect(key1 = replyToNote) { + accountViewModel.checkGetOrCreateNote(replyToNote) { + if (it != null) { + newPostModel.reply(it) + } + } + } + } + if (editFromDraft != null) { + LaunchedEffect(key1 = replyToNote) { + accountViewModel.checkGetOrCreateNote(editFromDraft) { + if (it != null) { + newPostModel.editFromDraft(it) + } + } + } + } + + if (room.users.size == 1) { + // Activates NIP-17 if the user has DM relays + ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) { + if (it?.relays().isNullOrEmpty()) { + newPostModel.nip17 = false + } else { + newPostModel.nip17 = true + } + } + } + + if (draftMessage != null) { + LaunchedEffect(key1 = draftMessage) { + newPostModel.updateMessage(TextFieldValue(draftMessage)) + } + } + + ChatroomViewUI( + room = room, + feedViewModel = feedViewModel, + newPostModel = newPostModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun ChatroomViewUI( + room: ChatroomKey, + feedViewModel: NostrChatroomFeedViewModel, + newPostModel: ChatNewMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) + + val lifeCycleOwner = LocalLifecycleOwner.current + + DisposableEffect(room, accountViewModel) { + NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) + NostrChatroomDataSource.start() + feedViewModel.invalidateData() + + onDispose { NostrChatroomDataSource.stop() } + } + + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Private Message Start") + NostrChatroomDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Private Message Stop") + NostrChatroomDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + Column(Modifier.fillMaxHeight()) { + ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) + + Column( + modifier = + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true), + ) { + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Room/${room.hashCode()}", + avoidDraft = newPostModel.draftTag, + onWantsToReply = newPostModel::reply, + onWantsToEditDraft = newPostModel::editFromDraft, + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + val scope = rememberCoroutineScope() + + // LAST ROW + PrivateMessageEditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = { + scope.launch { + feedViewModel.sendToTop() + } + }, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/feed/ChatDivisor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/feed/ChatDivisor.kt new file mode 100644 index 0000000000..fd4d6578b4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/feed/ChatDivisor.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.amethyst.ui.theme.StdPadding + +@Composable +fun ChatDivisor(info: String) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = StdPadding) { + HorizontalDivider( + modifier = Modifier.weight(1f), + thickness = DividerThickness, + ) + Text( + text = info, + fontWeight = FontWeight.Bold, + fontSize = Font14SP, + modifier = HalfPadding, + ) + HorizontalDivider( + modifier = Modifier.weight(1f), + thickness = DividerThickness, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/feed/ChatroomFeedView.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomFeedView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/feed/ChatroomFeedView.kt index 11f64078f3..806fc8395b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/feed/ChatroomFeedView.kt @@ -18,23 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -49,12 +44,9 @@ import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.messages.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Font14SP -import com.vitorpamplona.amethyst.ui.theme.HalfPadding -import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip37Drafts.DraftEvent @@ -101,13 +93,10 @@ fun RenderChatroomFeedView( CrossfadeIfEnabled(targetState = feedState, animationSpec = tween(durationMillis = 100), accountViewModel = accountViewModel) { state -> when (state) { - is FeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is FeedState.Loaded -> { + is FeedState.Loading -> LoadingFeed() + is FeedState.Empty -> FeedEmpty { viewModel.invalidateData() } + is FeedState.FeedError -> FeedError(state.errorMessage) { viewModel.invalidateData() } + is FeedState.Loaded -> ChatroomFeedLoaded( state, accountViewModel, @@ -118,10 +107,6 @@ fun RenderChatroomFeedView( onWantsToEditDraft, avoidDraft, ) - } - is FeedState.Loading -> { - LoadingFeed() - } } } } @@ -163,14 +148,14 @@ fun ChatroomFeedLoaded( onWantsToEditDraft = onWantsToEditDraft, ) - NewDateSubject(items.list.getOrNull(index + 1), item) + NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) } } } } @Composable -fun NewDateSubject( +fun NewDateOrSubjectDivisor( previous: Note?, note: Note, ) { @@ -196,23 +181,3 @@ fun NewDateSubject( } } } - -@Composable -fun ChatDivisor(info: String) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = StdPadding) { - HorizontalDivider( - modifier = Modifier.weight(1f), - thickness = DividerThickness, - ) - Text( - text = info, - fontWeight = FontWeight.Bold, - fontSize = Font14SP, - modifier = HalfPadding, - ) - HorizontalDivider( - modifier = Modifier.weight(1f), - thickness = DividerThickness, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt new file mode 100644 index 0000000000..10ea0292f3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser +import com.vitorpamplona.amethyst.ui.theme.Size34dp +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +@Composable +fun ChatroomHeader( + room: ChatroomKey, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + if (room.users.size == 1) { + LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> + if (baseUser != null) { + UserChatroomHeader( + baseUser = baseUser, + modifier = modifier, + accountViewModel = accountViewModel, + onClick = onClick, + ) + } + } + } else { + GroupChatroomHeader( + room = room, + modifier = modifier, + accountViewModel = accountViewModel, + onClick = onClick, + ) + } +} + +@Composable +fun UserChatroomHeader( + baseUser: User, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .clickable( + onClick = onClick, + ), + ) { + Column(modifier, Arrangement.Center) { + Row(verticalAlignment = Alignment.CenterVertically) { + ClickableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = Size34dp, + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + UsernameDisplay(baseUser, accountViewModel = accountViewModel) + } + } + } + } +} + +@Composable +fun GroupChatroomHeader( + room: ChatroomKey, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + verticalArrangement = Arrangement.Center, + modifier = modifier, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + NonClickableUserPictures( + room = room, + accountViewModel = accountViewModel, + size = Size34dp, + ) + + RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Bold, accountViewModel) + } + } + } +} 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 new file mode 100644 index 0000000000..70b2d3e2df --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt @@ -0,0 +1,162 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun NewChatroomSubjectDialog( + onClose: () -> Unit, + accountViewModel: AccountViewModel, + room: ChatroomKey, +) { + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + ), + ) { + Surface { + val groupName = + remember { + mutableStateOf(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "") + } + val message = remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + Column( + modifier = + Modifier + .padding(10.dp) + .verticalScroll(rememberScrollState()), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = { onClose() }) + + PostButton( + onPost = { + scope.launch(Dispatchers.IO) { + val template = + ChatMessageEvent.build( + message.value, + room.users.map { LocalCache.getOrCreateUser(it).toPTag() }, + ) { + groupName.value.ifBlank { null }?.let { changeSubject(it) } + } + + accountViewModel.account.sendNIP17PrivateMessage(template) + } + + onClose() + }, + true, + ) + } + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.messages_new_message_subject)) }, + modifier = Modifier.fillMaxWidth(), + value = groupName.value, + onValueChange = { groupName.value = it }, + placeholder = { + Text( + text = stringRes(R.string.messages_new_message_subject_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.messages_new_subject_message)) }, + modifier = + Modifier + .fillMaxWidth() + .height(100.dp), + value = message.value, + onValueChange = { message.value = it }, + placeholder = { + Text( + text = stringRes(R.string.messages_new_subject_message_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 10, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt new file mode 100644 index 0000000000..27b2a9b591 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -0,0 +1,198 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size34dp +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.ZeroPadding +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.collections.immutable.toPersistentList + +@Composable +fun RenderRoomTopBar( + room: ChatroomKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (room.users.size == 1) { + TopBarExtensibleWithBackButton( + title = { + LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> + if (baseUser != null) { + ClickableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = Size34dp, + ) + + Spacer(modifier = DoubleHorzSpacer) + + UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) + } + } + }, + extendableRow = { + LoadUser(baseUserHex = room.users.first(), accountViewModel) { + if (it != null) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + }, + popBack = nav::popBack, + ) + } else { + TopBarExtensibleWithBackButton( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + NonClickableUserPictures( + room = room, + accountViewModel = accountViewModel, + size = Size34dp, + ) + + RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel) + } + }, + extendableRow = { + GroupMembersHeader(room = room, accountViewModel = accountViewModel, nav = nav) + }, + popBack = nav::popBack, + ) + } +} + +@Composable +fun GroupMembersHeader( + room: ChatroomKey, + lineModifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: INav, +) { + val list = remember(room) { room.users.toPersistentList() } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(id = R.string.messages_group_descriptor), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) + + EditRoomSubjectButton(room, accountViewModel) + } + + LazyColumn( + modifier = Modifier, + state = rememberLazyListState(), + ) { + itemsIndexed(list, key = { _, item -> item }) { _, item -> + LoadUser(baseUserHex = item, accountViewModel) { + if (it != null) { + UserCompose( + baseUser = it, + overallModifier = lineModifier, + accountViewModel = accountViewModel, + nav = nav, + ) + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } +} + +@Composable +private fun EditRoomSubjectButton( + room: ChatroomKey, + accountViewModel: AccountViewModel, +) { + var wantsToPost by remember { mutableStateOf(false) } + + if (wantsToPost) { + NewChatroomSubjectDialog({ wantsToPost = false }, accountViewModel, room) + } + + Button( + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), + onClick = { wantsToPost = true }, + contentPadding = ZeroPadding, + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.EditNote, + contentDescription = stringRes(R.string.edits_the_channel_metadata), + ) + } +} 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 new file mode 100644 index 0000000000..668f6c48a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt @@ -0,0 +1,192 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlin.math.min + +@Composable +fun RoomNameOnlyDisplay( + room: ChatroomKey, + modifier: Modifier, + fontWeight: FontWeight = FontWeight.Bold, + accountViewModel: AccountViewModel, +) { + val roomSubject by + accountViewModel + .userProfile() + .live() + .messages + .map { it.user.privateChatrooms[room]?.subject } + .distinctUntilChanged() + .observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) + + CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) { + if (!it.isNullOrBlank()) { + DisplayRoomSubject(it, fontWeight) + } else { + DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal) + } + } +} + +@Composable +fun DisplayUserSetAsSubject( + room: ChatroomKey, + accountViewModel: AccountViewModel, + fontWeight: FontWeight = FontWeight.Bold, +) { + val userList = remember(room) { room.users.toList() } + + if (userList.size == 1) { + // Regular Design + Row { + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight, accountViewModel = accountViewModel) } + } + } + } else { + Row { + userList.take(4).forEachIndexed { index, value -> + LoadUser(baseUserHex = value, accountViewModel) { + it?.let { ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight, accountViewModel = accountViewModel) } + } + + if (min(userList.size, 4) - 1 != index) { + Text( + text = ", ", + fontWeight = fontWeight, + maxLines = 1, + ) + } + } + } + } +} + +@Composable +fun RoomNameDisplay( + room: ChatroomKey, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + val roomSubject by + accountViewModel + .userProfile() + .live() + .messages + .map { it.user.privateChatrooms[room]?.subject } + .distinctUntilChanged() + .observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) + + CrossfadeIfEnabled(targetState = roomSubject, modifier, label = "RoomNameDisplay", accountViewModel = accountViewModel) { + if (!it.isNullOrBlank()) { + if (room.users.size > 1) { + DisplayRoomSubject(it) + } else { + DisplayUserAndSubject(room.users.first(), it, accountViewModel) + } + } else { + DisplayUserSetAsSubject(room, accountViewModel) + } + } +} + +@Composable +fun DisplayRoomSubject( + roomSubject: String, + fontWeight: FontWeight = FontWeight.Bold, +) { + Row { + Text( + text = roomSubject, + fontWeight = fontWeight, + maxLines = 1, + ) + } +} + +@Composable +private fun DisplayUserAndSubject( + user: HexKey, + subject: String, + accountViewModel: AccountViewModel, +) { + Row { + Text( + text = subject, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = " - ", + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + LoadUser(baseUserHex = user, accountViewModel = accountViewModel) { + it?.let { UsernameDisplay(it, Modifier.weight(1f), accountViewModel = accountViewModel) } + } + } +} + +@Composable +fun ShortUsernameDisplay( + baseUser: User, + weight: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + accountViewModel: AccountViewModel, +) { + val userName by + baseUser + .live() + .metadata + .map { it.user.toBestShortFirstName() } + .distinctUntilChanged() + .observeAsState(baseUser.toBestShortFirstName()) + + CrossfadeIfEnabled(targetState = userName, modifier = weight, accountViewModel = accountViewModel) { + CreateTextWithEmoji( + text = it, + tags = baseUser.info?.tags, + fontWeight = fontWeight, + maxLines = 1, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatBubbleLayout.kt new file mode 100644 index 0000000000..56984c4dd9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatBubbleLayout.kt @@ -0,0 +1,321 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.messages + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier +import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe +import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem +import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier +import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier +import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.chatBackground +import com.vitorpamplona.amethyst.ui.theme.chatDraftBackground +import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink +import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatBubbleLayout( + isLoggedInUser: Boolean, + isDraft: Boolean, + innerQuote: Boolean, + isComplete: Boolean, + hasDetailsToShow: Boolean, + drawAuthorInfo: Boolean, + parentBackgroundColor: MutableState? = null, + onClick: () -> Boolean, + onAuthorClick: () -> Unit, + actionMenu: @Composable (onDismiss: () -> Unit) -> Unit, + detailRow: @Composable () -> Unit, + drawAuthorLine: @Composable () -> Unit, + inner: @Composable (MutableState) -> Unit, +) { + val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink + val otherColors = MaterialTheme.colorScheme.chatBackground + val defaultBackground = MaterialTheme.colorScheme.background + val draftColor = MaterialTheme.colorScheme.chatDraftBackground + + val backgroundBubbleColor = + remember { + if (isLoggedInUser) { + if (isDraft) { + mutableStateOf( + draftColor.compositeOver(parentBackgroundColor?.value ?: defaultBackground), + ) + } else { + mutableStateOf( + loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground), + ) + } + } else { + mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground)) + } + } + + Row( + modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, + horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, + ) { + val popupExpanded = remember { mutableStateOf(false) } + + val showDetails = + remember { + mutableStateOf( + if (isComplete) { + true + } else { + hasDetailsToShow + }, + ) + } + + val clickableModifier = + remember { + Modifier.combinedClickable( + onClick = { + if (!onClick()) { + if (!isComplete) { + showDetails.value = !showDetails.value + } + } + }, + onLongClick = { popupExpanded.value = true }, + ) + } + + Row( + horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, + modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier, + ) { + Surface( + color = backgroundBubbleColor.value, + shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem, + modifier = clickableModifier, + ) { + Column(modifier = messageBubbleLimits, verticalArrangement = RowColSpacing5dp) { + if (drawAuthorInfo) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, + modifier = HalfHalfVertPadding.clickable(onClick = onAuthorClick), + ) { + drawAuthorLine() + } + } + + inner(backgroundBubbleColor) + + if (showDetails.value) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = ReactionRowHeightChat, + ) { + detailRow() + } + } + } + } + } + + if (popupExpanded.value) { + actionMenu { + popupExpanded.value = false + } + } + } +} + +@Preview +@Composable +private fun BubblePreview() { + val backgroundBubbleColor = + remember { + mutableStateOf(Color.Transparent) + } + + Column { + ChatBubbleLayout( + isLoggedInUser = false, + isDraft = false, + innerQuote = false, + isComplete = true, + hasDetailsToShow = true, + drawAuthorInfo = true, + parentBackgroundColor = backgroundBubbleColor, + onClick = { false }, + onAuthorClick = {}, + actionMenu = { onDismiss -> + }, + drawAuthorLine = { + UserDisplayNameLayout( + picture = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = + Modifier + .size(Size20dp) + .clip(CircleShape) + .background(Color.LightGray), + ) + }, + name = { + Text("Someone else", fontWeight = FontWeight.Bold) + }, + ) + }, + detailRow = { Text("Relays and Actions") }, + ) { backgroundBubbleColor -> + Text("This is my note") + } + + ChatBubbleLayout( + isLoggedInUser = true, + isDraft = false, + innerQuote = false, + isComplete = true, + hasDetailsToShow = true, + drawAuthorInfo = true, + parentBackgroundColor = backgroundBubbleColor, + onClick = { false }, + onAuthorClick = {}, + actionMenu = { onDismiss -> + }, + drawAuthorLine = { + UserDisplayNameLayout( + picture = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = + Modifier + .size(Size20dp) + .clip(CircleShape), + ) + }, + name = { + Text("Me", fontWeight = FontWeight.Bold) + }, + ) + }, + detailRow = { Text("Relays and Actions") }, + ) { backgroundBubbleColor -> + Text("This is a very long long loong note") + } + + ChatBubbleLayout( + isLoggedInUser = true, + isDraft = true, + innerQuote = false, + isComplete = true, + hasDetailsToShow = true, + drawAuthorInfo = true, + parentBackgroundColor = backgroundBubbleColor, + onClick = { false }, + onAuthorClick = {}, + actionMenu = { onDismiss -> + }, + drawAuthorLine = { + UserDisplayNameLayout( + picture = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = + Modifier + .size(Size20dp) + .clip(CircleShape), + ) + }, + name = { + Text("Me", fontWeight = FontWeight.Bold) + }, + ) + }, + detailRow = { Text("Relays and Actions") }, + ) { backgroundBubbleColor -> + Text("This is a draft note") + } + + ChatBubbleLayout( + isLoggedInUser = true, + isDraft = false, + innerQuote = false, + isComplete = false, + hasDetailsToShow = false, + drawAuthorInfo = false, + parentBackgroundColor = backgroundBubbleColor, + onClick = { false }, + onAuthorClick = {}, + actionMenu = { onDismiss -> + }, + drawAuthorLine = { + UserDisplayNameLayout( + picture = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = + Modifier + .size(Size20dp) + .clip(CircleShape), + ) + }, + name = { + Text("Me", fontWeight = FontWeight.Bold) + }, + ) + }, + detailRow = { Text("Relays and Actions") }, + ) { backgroundBubbleColor -> + Text("Short note") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatroomMessageCompose.kt similarity index 66% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatroomMessageCompose.kt index 6b441473e0..9ab47b27e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatroomMessageCompose.kt @@ -18,25 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.messages -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -44,17 +34,13 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note @@ -80,36 +66,25 @@ import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.note.types.RenderEncryptedFile import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier -import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe -import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem -import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier -import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding -import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat import com.vitorpamplona.amethyst.ui.theme.RowColSpacing -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size5Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.chatAuthorBox -import com.vitorpamplona.amethyst.ui.theme.chatBackground import com.vitorpamplona.amethyst.ui.theme.incognitoIconModifier -import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink -import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEncryptedFileHeaderEvent -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable -import com.vitorpamplona.quartz.nip17Dm.NIP17Group -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent @Composable @@ -188,6 +163,7 @@ fun NormalChatNote( ChatBubbleLayout( isLoggedInUser = isLoggedInUser, + isDraft = note.event is DraftEvent, innerQuote = innerQuote, isComplete = accountViewModel.settings.featureSet == FeatureSetType.COMPLETE, hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(), @@ -223,27 +199,29 @@ fun NormalChatNote( ) }, detailRow = { - if (note.isDraft()) { - DisplayDraftChat() - } IncognitoBadge(note) ChatTimeAgo(note) RelayBadgesHorizontal(note, accountViewModel, nav = nav) + Spacer(modifier = DoubleHorzSpacer) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) { - ReplyReaction( - baseNote = note, - grayTint = MaterialTheme.colorScheme.placeholderText, - accountViewModel = accountViewModel, - showCounter = false, - iconSizeModifier = Size18Modifier, - ) { - onWantsToReply(note) - } - Spacer(modifier = StdHorzSpacer) - LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) + if (!note.isDraft()) { + ReplyReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.placeholderText, + accountViewModel = accountViewModel, + showCounter = false, + iconSizeModifier = Size18Modifier, + ) { + onWantsToReply(note) + } + Spacer(modifier = StdHorzSpacer) + LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) - ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + } else { + DisplayDraftChat() + } } }, ) { backgroundBubbleColor -> @@ -260,221 +238,6 @@ fun NormalChatNote( } } -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ChatBubbleLayout( - isLoggedInUser: Boolean, - innerQuote: Boolean, - isComplete: Boolean, - hasDetailsToShow: Boolean, - drawAuthorInfo: Boolean, - parentBackgroundColor: MutableState? = null, - onClick: () -> Boolean, - onAuthorClick: () -> Unit, - actionMenu: @Composable (onDismiss: () -> Unit) -> Unit, - detailRow: @Composable () -> Unit, - drawAuthorLine: @Composable () -> Unit, - inner: @Composable (MutableState) -> Unit, -) { - val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink - val otherColors = MaterialTheme.colorScheme.chatBackground - val defaultBackground = MaterialTheme.colorScheme.background - - val backgroundBubbleColor = - remember { - if (isLoggedInUser) { - mutableStateOf( - loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground), - ) - } else { - mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground)) - } - } - - Row( - modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, - horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, - ) { - val popupExpanded = remember { mutableStateOf(false) } - - val showDetails = - remember { - mutableStateOf( - if (isComplete) { - true - } else { - hasDetailsToShow - }, - ) - } - - val clickableModifier = - remember { - Modifier.combinedClickable( - onClick = { - if (!onClick()) { - if (!isComplete) { - showDetails.value = !showDetails.value - } - } - }, - onLongClick = { popupExpanded.value = true }, - ) - } - - Row( - horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, - modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier, - ) { - Surface( - color = backgroundBubbleColor.value, - shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem, - modifier = clickableModifier, - ) { - Column(modifier = messageBubbleLimits, verticalArrangement = RowColSpacing5dp) { - if (drawAuthorInfo) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, - modifier = HalfHalfVertPadding.clickable(onClick = onAuthorClick), - ) { - drawAuthorLine() - } - } - - inner(backgroundBubbleColor) - - if (showDetails.value) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChat, - ) { - detailRow() - } - } - } - } - } - - if (popupExpanded.value) { - actionMenu { - popupExpanded.value = false - } - } - } -} - -@Preview -@Composable -private fun BubblePreview() { - val backgroundBubbleColor = - remember { - mutableStateOf(Color.Transparent) - } - - Column { - ChatBubbleLayout( - isLoggedInUser = false, - innerQuote = false, - isComplete = true, - hasDetailsToShow = true, - drawAuthorInfo = true, - parentBackgroundColor = backgroundBubbleColor, - onClick = { false }, - onAuthorClick = {}, - actionMenu = { onDismiss -> - }, - drawAuthorLine = { - UserDisplayNameLayout( - picture = { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = - Modifier - .size(Size20dp) - .clip(CircleShape) - .background(Color.LightGray), - ) - }, - name = { - Text("Someone else", fontWeight = FontWeight.Bold) - }, - ) - }, - detailRow = { Text("Relays and Actions") }, - ) { backgroundBubbleColor -> - Text("This is my note") - } - - ChatBubbleLayout( - isLoggedInUser = true, - innerQuote = false, - isComplete = true, - hasDetailsToShow = true, - drawAuthorInfo = true, - parentBackgroundColor = backgroundBubbleColor, - onClick = { false }, - onAuthorClick = {}, - actionMenu = { onDismiss -> - }, - drawAuthorLine = { - UserDisplayNameLayout( - picture = { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = - Modifier - .size(Size20dp) - .clip(CircleShape), - ) - }, - name = { - Text("Me", fontWeight = FontWeight.Bold) - }, - ) - }, - detailRow = { Text("Relays and Actions") }, - ) { backgroundBubbleColor -> - Text("This is a very long long loong note") - } - - ChatBubbleLayout( - isLoggedInUser = true, - innerQuote = false, - isComplete = false, - hasDetailsToShow = false, - drawAuthorInfo = false, - parentBackgroundColor = backgroundBubbleColor, - onClick = { false }, - onAuthorClick = {}, - actionMenu = { onDismiss -> - }, - drawAuthorLine = { - UserDisplayNameLayout( - picture = { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = - Modifier - .size(Size20dp) - .clip(CircleShape), - ) - }, - name = { - Text("Me", fontWeight = FontWeight.Bold) - }, - ) - }, - detailRow = { Text("Relays and Actions") }, - ) { backgroundBubbleColor -> - Text("Short note") - } - } -} - @Composable private fun MessageBubbleLines( baseNote: Note, 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 new file mode 100644 index 0000000000..3d00cec2aa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -0,0 +1,596 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.compose.currentWord +import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.UserSuggestionAnchor +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.components.Split +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.UUID + +@Stable +open class ChatNewMessageViewModel : ViewModel() { + var draftTag: String by mutableStateOf(UUID.randomUUID().toString()) + + var accountViewModel: AccountViewModel? = null + var account: Account? = null + var room: ChatroomKey? = null + + var requiresNIP17: Boolean = false + + val replyTo = mutableStateOf(null) + + var uploadState by mutableStateOf(null) + val iMetaAttachments = IMetaAttachments() + + var message by mutableStateOf(TextFieldValue("")) + var urlPreview by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + + val userSuggestions = UserSuggestions() + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + val emojiSearch: MutableStateFlow = MutableStateFlow("") + val emojiSuggestions: StateFlow> by lazy { + account!! + .myEmojis + .combine(emojiSearch) { list, search -> + if (search.length == 1) { + list + } else if (search.isNotEmpty()) { + val code = search.removePrefix(":") + list.filter { it.code.startsWith(code) } + } else { + emptyList() + } + }.flowOn(Dispatchers.Default) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList(), + ) + } + + var toUsers by mutableStateOf(TextFieldValue("")) + var subject by mutableStateOf(TextFieldValue("")) + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + var forwardZapTo by mutableStateOf>(Split()) + var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + var zapRaiserAmount by mutableStateOf(null) + + // NIP17 Wrapped DMs / Group messages + var nip17 by mutableStateOf(false) + + val draftTextChanges = Channel(Channel.CONFLATED) + + fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() + + fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null + + fun user(): User? = account?.userProfile() + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + this.canAddInvoice = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() + + this.uploadState = + ChatFileUploadState( + account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0], + ) + } + + open fun load(room: ChatroomKey) { + this.room = room + this.requiresNIP17 = room.users.size > 1 + if (this.requiresNIP17) { + this.nip17 = true + } + } + + open fun reply(replyNote: Note) { + replyTo.value = replyNote + } + + open fun quote(quote: Note) { + message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + urlPreview = findUrlInMessage() + + // creates a split with that author. + val accountViewModel = accountViewModel ?: return + val quotedAuthor = quote.author ?: return + + if (quotedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.items.none { it.key.pubkeyHex == quotedAuthor.pubkeyHex }) { + forwardZapTo.addItem(quotedAuthor) + } + if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { + forwardZapTo.addItem(accountViewModel.userProfile()) + } + + val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedAuthor.pubkeyHex } + forwardZapTo.updatePercentage(pos, 0.9f) + + wantsForwardZapTo = true + } + } + + open fun editFromDraft(draft: Note) { + val noteEvent = draft.event + val noteAuthor = draft.author + + if (noteEvent is DraftEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel?.createTempDraftNote(noteEvent) { innerNote -> + if (innerNote != null) { + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag = oldTag + } + loadFromDraft(innerNote) + } + } + } + } + } + + private fun loadFromDraft(draft: Note) { + Log.d("draft", draft.event!!.toJson()) + + val draftEvent = draft.event ?: return + val accountViewModel = accountViewModel ?: return + + val localfowardZapTo = draftEvent.tags.zapSplitSetup() + val totalWeight = localfowardZapTo.sumOf { it.weight } + forwardZapTo = Split() + localfowardZapTo.forEach { + if (it is ZapSplitSetup) { + val user = LocalCache.getOrCreateUser(it.pubKeyHex) + forwardZapTo.addItem(user, (it.weight / totalWeight).toFloat()) + } + // don't support edditing old-style splits. + } + forwardZapToEditting = TextFieldValue("") + wantsForwardZapTo = localfowardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val zapraiser = draftEvent.zapraiserAmount() + wantsZapraiser = zapraiser != null + zapRaiserAmount = null + if (zapraiser != null) { + zapRaiserAmount = zapraiser + } + + if (forwardZapTo.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + draftEvent.subject()?.let { + subject = TextFieldValue() + } + + if (draftEvent is NIP17Group) { + toUsers = + TextFieldValue( + draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) + } else if (draftEvent is PrivateDmEvent) { + val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } + toUsers = TextFieldValue("@$recepientNpub") + } + + message = + if (draftEvent is PrivateDmEvent) { + TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "") + } else { + TextFieldValue(draftEvent.content) + } + + requiresNIP17 = draftEvent is NIP17Group + nip17 = draftEvent is NIP17Group + + urlPreview = findUrlInMessage() + } + + fun sendPost(onDone: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + sendPostSync() + onDone() + } + } + + suspend fun sendPostSync() { + innerSendPost(null) + accountViewModel?.deleteDraft(draftTag) + cancel() + } + + fun sendDraft() { + viewModelScope.launch(Dispatchers.IO) { + sendDraftSync() + } + } + + suspend fun sendDraftSync() { + if (message.text.isBlank()) { + account?.deleteDraft(draftTag) + } else { + innerSendPost(draftTag) + } + } + + fun pickedMedia(list: ImmutableList) { + uploadState?.load(list) + } + + fun upload( + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: () -> Unit, + ) { + val room = room ?: return + val account = account ?: return + val uploadState = uploadState ?: return + + if (nip17) { + ChatFileUploader(room, account).uploadNIP17(uploadState, viewModelScope, onError, context, onceUploaded) + } else { + ChatFileUploader(room, account).uploadNIP04(uploadState, viewModelScope, onError, context, onceUploaded) + } + } + + private fun innerSendPost(dTag: String?) { + val room = room ?: return + val accountViewModel = accountViewModel ?: return + + val urls = findURLs(message.text) + val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + val emojis = findEmoji(message.text, accountViewModel.account.myEmojis.value) + + val message = message.text + + if (nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) { + val replyHint = replyTo.value?.toEventHint() + + val template = + if (replyHint == null) { + ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) { + hashtags(findHashtags(message)) + references(findURLs(message)) + quotes(findNostrUris(message)) + + emojis(emojis) + imetas(usedAttachments) + } + } else { + ChatMessageEvent.reply(message, replyHint) { + hashtags(findHashtags(message)) + references(findURLs(message)) + quotes(findNostrUris(message)) + + emojis(emojis) + imetas(usedAttachments) + } + } + + accountViewModel.account.sendNIP17PrivateMessage(template, dTag) + } else { + accountViewModel.account.sendPrivateMessage( + message = message, + toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }, + replyingTo = replyTo.value, + contentWarningReason = null, + imetas = usedAttachments, + draftTag = dTag, + ) + } + } + + fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.url.url) } + } + } + + open fun cancel() { + message = TextFieldValue("") + toUsers = TextFieldValue("") + subject = TextFieldValue("") + + replyTo.value = null + + urlPreview = null + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount = null + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + + forwardZapTo = Split() + forwardZapToEditting = TextFieldValue("") + + userSuggestions.reset() + userSuggestionsMainMessage = null + + if (emojiSearch.value.isNotEmpty()) { + emojiSearch.tryEmit("") + } + + draftTag = UUID.randomUUID().toString() + + NostrSearchEventOrUserDataSource.clear() + } + + fun deleteDraft() { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel?.deleteDraft(draftTag) + } + } + + open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull() + + private fun saveDraft() { + draftTextChanges.trySend("") + } + + open fun addToMessage(it: String) { + updateMessage(TextFieldValue(message.text + " " + it)) + } + + open fun updateMessage(newMessage: TextFieldValue) { + message = newMessage + urlPreview = findUrlInMessage() + + if (newMessage.selection.collapsed) { + val lastWord = newMessage.currentWord() + + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + + accountViewModel?.let { + userSuggestions.processCurrentWord(lastWord, it) + } + + if (lastWord.startsWith(":")) { + emojiSearch.tryEmit(lastWord) + } else { + if (emojiSearch.value.isNotBlank()) { + emojiSearch.tryEmit("") + } + } + } + + saveDraft() + } + + open fun updateToUsers(newToUsersValue: TextFieldValue) { + toUsers = newToUsersValue + + if (newToUsersValue.selection.collapsed) { + val lastWord = newToUsersValue.currentWord() + userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS + + accountViewModel?.let { + userSuggestions.processCurrentWord(lastWord, it) + } + } + saveDraft() + } + + open fun updateSubject(it: TextFieldValue) { + subject = it + saveDraft() + } + + open fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { + forwardZapToEditting = newZapForwardTo + if (newZapForwardTo.selection.collapsed) { + val lastWord = newZapForwardTo.text + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + accountViewModel?.let { + userSuggestions.processCurrentWord(lastWord, it) + } + } + } + + open fun autocompleteWithUser(item: User) { + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + message = userSuggestions.replaceCurrentWord(message, lastWord, item) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.addItem(item) + forwardZapToEditting = TextFieldValue("") + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { + val lastWord = toUsers.currentWord() + toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) + + val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelays() + nip17 = relayList != null + } + + userSuggestionsMainMessage = null + userSuggestions.reset() + + saveDraft() + } + + open fun autocompleteWithEmoji(item: Account.EmojiMedia) { + val wordToInsert = ":${item.code}:" + message = message.replaceCurrentWord(wordToInsert) + + emojiSearch.tryEmit("") + + saveDraft() + } + + open fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) { + val wordToInsert = item.url.url + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaAttachments.downloadAndPrepare( + item.url.url, + accountViewModel?.account?.shouldUseTorForImageDownload() ?: false, + ) + } + + message = message.replaceCurrentWord(wordToInsert) + + emojiSearch.tryEmit("") + + urlPreview = findUrlInMessage() + + saveDraft() + } + + fun canPost(): Boolean = + message.text.isNotBlank() && + uploadState?.isUploadingImage != true && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount != null) && + (toUsers.text.isNotBlank()) && + uploadState?.multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } + + fun toggleNIP04And24() { + if (requiresNIP17) { + nip17 = true + } else { + nip17 = !nip17 + } + if (message.text.isNotBlank()) { + saveDraft() + } + } + + fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.updatePercentage(index, sliderValue) + } + + fun updateZapFromText() { + viewModelScope.launch(Dispatchers.Default) { + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.items.any { it.key == taggedUser }) { + forwardZapTo.addItem(taggedUser) + } + } + } + } + + fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount = newAmount + saveDraft() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + saveDraft() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt new file mode 100644 index 0000000000..bb4663297c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send + +import android.webkit.MimeTypeMap +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.service.uploads.FileHeader +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip92IMeta.imetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.alt +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dims +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent +import com.vitorpamplona.quartz.nip94FileMetadata.size +import java.util.Locale + +class IMetaAttachments { + var iMetaAttachments by mutableStateOf>(emptyList()) + + suspend fun downloadAndPrepare( + url: String, + forceProxy: Boolean, + ) { + val fileExtension: String = MimeTypeMap.getFileExtensionFromUrl(url) + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault())) + + val imeta = + FileHeader.prepare(url, mimeType, null, forceProxy).getOrNull()?.let { + IMetaTagBuilder(url) + .apply { + hash(it.hash) + size(it.size) + it.mimeType?.let { mimeType(it) } + it.dim?.let { dims(it) } + it.blurHash?.let { blurhash(it.blurhash) } + }.build() + } + + if (imeta != null) { + iMetaAttachments += imeta + } + } + + fun remove(url: String) { + iMetaAttachments = iMetaAttachments.filter { it.url != url } + } + + fun replace( + url: String, + iMeta: IMetaTag, + ) { + iMetaAttachments = iMetaAttachments.filter { it.url != url } + iMeta + } + + fun add( + result: UploadOrchestrator.OrchestratorResult.ServerResult, + alt: String?, + contentWarningReason: String?, + ) { + val iMeta = + imetaTagBuilder(result.url) { + hash(result.fileHeader.hash) + size(result.fileHeader.size) + result.fileHeader.mimeType?.let { mimeType(it) } + result.fileHeader.dim?.let { dims(it) } + result.fileHeader.blurHash?.let { blurhash(it.blurhash) } + result.magnet?.let { magnet(it) } + result.uploadedHash?.let { originalHash(it) } + + alt?.let { alt(it) } + contentWarningReason?.let { sensitiveContent(contentWarningReason) } + } + + replace(iMeta.url, iMeta) + } + + fun filterIsIn(urls: Set) = iMetaAttachments.filter { it.url !in urls } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt new file mode 100644 index 0000000000..6c4e3dba44 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -0,0 +1,253 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.IncognitoIconOff +import com.vitorpamplona.amethyst.ui.note.IncognitoIconOn +import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog +import com.vitorpamplona.amethyst.ui.note.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploadDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier +import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.Size30Modifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +@Composable +fun PrivateMessageEditFieldRow( + channelScreenModel: ChatNewMessageViewModel, + accountViewModel: AccountViewModel, + onSendNewMessage: () -> Unit, + nav: INav, +) { + channelScreenModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { channelScreenModel.replyTo.value = null } } + + LaunchedEffect(key1 = channelScreenModel.draftTag) { + launch(Dispatchers.IO) { + channelScreenModel.draftTextChanges + .receiveAsFlow() + .debounce(1000) + .collectLatest { + channelScreenModel.sendDraft() + } + } + } + + channelScreenModel.uploadState?.let { uploading -> + uploading.multiOrchestrator?.let { selectedFiles -> + val context = LocalContext.current + + ChatFileUploadDialog( + room = channelScreenModel.room!!, + state = uploading, + upload = { + channelScreenModel.upload( + onError = accountViewModel::toast, + context = context, + onceUploaded = onSendNewMessage, + ) + + if (uploading.selectedServer.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(uploading.selectedServer) + } + }, + onCancel = uploading::reset, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + Column( + modifier = EditFieldModifier, + ) { + ShowUserSuggestionList( + channelScreenModel.userSuggestions.userSuggestions, + channelScreenModel::autocompleteWithUser, + accountViewModel, + ) + + ShowEmojiSuggestionList( + channelScreenModel.emojiSuggestions, + channelScreenModel::autocompleteWithEmoji, + channelScreenModel::autocompleteWithEmojiUrl, + accountViewModel, + ) + + ThinPaddingTextField( + value = channelScreenModel.message, + onValueChange = { channelScreenModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + shape = EditFieldBorder, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringRes(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + ThinSendButton( + isActive = + channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, + modifier = EditFieldTrailingIconModifier, + ) { + channelScreenModel.sendPost(onSendNewMessage) + } + }, + leadingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 6.dp), + ) { + SelectFromGallery( + isUploading = channelScreenModel.isUploadingImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = + Modifier + .size(30.dp) + .padding(start = 2.dp), + onImageChosen = channelScreenModel::pickedMedia, + ) + + var wantsToActivateNIP17 by remember { mutableStateOf(false) } + + if (wantsToActivateNIP17) { + NewFeatureNIP17AlertDialog( + accountViewModel = accountViewModel, + onConfirm = { channelScreenModel.toggleNIP04And24() }, + onDismiss = { wantsToActivateNIP17 = false }, + ) + } + + IconButton( + modifier = Size30Modifier, + onClick = { + if ( + !accountViewModel.account.settings.hideNIP17WarningDialog && + !channelScreenModel.nip17 && + !channelScreenModel.requiresNIP17 + ) { + wantsToActivateNIP17 = true + } else { + channelScreenModel.toggleNIP04And24() + } + }, + ) { + if (channelScreenModel.nip17) { + IncognitoIconOn( + modifier = + Modifier + .padding(top = 2.dp) + .size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } else { + IncognitoIconOff( + modifier = + Modifier + .padding(top = 2.dp) + .size(18.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + ) + } +} + +@Composable +fun NewFeatureNIP17AlertDialog( + accountViewModel: AccountViewModel, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + + QuickActionAlertDialog( + title = stringRes(R.string.new_feature_nip17_might_not_be_available_title), + textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description), + buttonIconResource = R.drawable.incognito, + buttonText = stringRes(R.string.new_feature_nip17_activate), + onClickDoOnce = { + scope.launch { onConfirm() } + onDismiss() + }, + onClickDontShowAgain = { + scope.launch { + onConfirm() + accountViewModel.account.settings.setHideNIP17WarningDialog() + } + onDismiss() + }, + onDismiss = onDismiss, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/UserSuggestions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/UserSuggestions.kt new file mode 100644 index 0000000000..29d9ab52fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/UserSuggestions.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class UserSuggestions { + var userSuggestions by mutableStateOf>(emptyList()) + + fun reset() { + userSuggestions = emptyList() + } + + fun processCurrentWord( + word: String, + accountViewModel: AccountViewModel, + ) { + if (word.startsWith("@") && word.length > 2) { + val prefix = word.removePrefix("@") + NostrSearchEventOrUserDataSource.search(prefix) + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + userSuggestions = accountViewModel.findUsersStartingWithSync(prefix) + } + } else { + NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } + } + + fun replaceCurrentWord( + message: TextFieldValue, + word: String, + item: User, + ): TextFieldValue { + val lastWordStart = message.selection.end - word.length + val wordToInsert = "@${item.pubkeyNpub()}" + + return TextFieldValue( + message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index 445a261fe1..127aed8c70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -51,10 +51,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -72,28 +70,29 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingSwitchItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ChatFileUploadView( - postViewModel: ChatFileUploadModel, - onClose: () -> Unit, +fun ChatFileUploadDialog( + room: ChatroomKey, + state: ChatFileUploadState, + upload: () -> Unit, + onCancel: () -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { - val account = accountViewModel.account - val context = LocalContext.current - val scrollState = rememberScrollState() Dialog( - onDismissRequest = { onClose() }, + onDismissRequest = { onCancel() }, properties = DialogProperties( usePlatformDefaultWidth = false, @@ -108,34 +107,22 @@ fun ChatFileUploadView( scrollBehavior = rememberHeightDecreaser(), modifier = Modifier, title = { - val room = postViewModel.chatroom - - if (room == null) { - Text( - text = stringRes(R.string.dm_upload), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, + Row(verticalAlignment = Alignment.CenterVertically) { + NonClickableUserPictures( + room = room, + accountViewModel = accountViewModel, + size = Size34dp, ) - } else { - Row(verticalAlignment = Alignment.CenterVertically) { - NonClickableUserPictures( - room = room, - accountViewModel = accountViewModel, - size = Size34dp, - ) - RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Normal, accountViewModel) - } + RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Normal, accountViewModel) } }, navigationIcon = { IconButton( modifier = TitleIconModifier, onClick = { - postViewModel.cancelModel() - onClose() + state.reset() + onCancel() }, ) { ArrowBackIcon() @@ -144,21 +131,8 @@ fun ChatFileUploadView( actions = { SendButton( modifier = Modifier.padding(end = 5.dp), - onPost = { - postViewModel.upload( - onError = accountViewModel::toast, - context = context, - ) { - onClose - } - - postViewModel.selectedServer?.let { - if (it.type != ServerType.NIP95) { - account.settings.changeDefaultFileServer(it) - } - } - }, - isActive = postViewModel.canPost(), + onPost = upload, + isActive = state.canPost(), ) }, colors = @@ -177,7 +151,7 @@ fun ChatFileUploadView( ) { Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { - ImageVideoPostChat(postViewModel, accountViewModel) + ImageVideoPostChat(state, accountViewModel) } } } @@ -187,7 +161,7 @@ fun ChatFileUploadView( @Composable private fun ImageVideoPostChat( - postViewModel: ChatFileUploadModel, + fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, ) { val fileServers by accountViewModel.account.liveServerList.collectAsState() @@ -204,10 +178,10 @@ private fun ImageVideoPostChat( }.toImmutableList() } - postViewModel.multiOrchestrator?.let { + fileUploadState.multiOrchestrator?.let { ShowImageUploadGallery( it, - postViewModel::deleteMediaToUpload, + fileUploadState::deleteMediaToUpload, accountViewModel, ) } @@ -216,8 +190,8 @@ private fun ImageVideoPostChat( label = { Text(text = stringRes(R.string.content_description)) }, modifier = Modifier.fillMaxWidth().padding(top = 3.dp).height(150.dp), maxLines = 10, - value = postViewModel.caption, - onValueChange = { postViewModel.caption = it }, + value = fileUploadState.caption, + onValueChange = { fileUploadState.caption = it }, placeholder = { Text( text = stringRes(R.string.content_description_example), @@ -234,8 +208,8 @@ private fun ImageVideoPostChat( title = R.string.add_sensitive_content_label, description = R.string.add_sensitive_content_description, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - checked = postViewModel.sensitiveContent, - onCheckedChange = { postViewModel.sensitiveContent = it }, + checked = fileUploadState.sensitiveContent, + onCheckedChange = { fileUploadState.sensitiveContent = it }, ) SettingsRow(R.string.file_server, R.string.file_server_description) { @@ -247,7 +221,7 @@ private fun ImageVideoPostChat( ?.name ?: fileServers[0].name, options = fileServerOptions, - onSelect = { postViewModel.selectedServer = fileServers[it] }, + onSelect = { fileUploadState.selectedServer = fileServers[it] }, ) } @@ -273,7 +247,7 @@ private fun ImageVideoPostChat( Box(modifier = Modifier.fillMaxWidth()) { Text( text = - when (postViewModel.mediaQualitySlider) { + when (fileUploadState.mediaQualitySlider) { 0 -> stringRes(R.string.media_compression_quality_low) 1 -> stringRes(R.string.media_compression_quality_medium) 2 -> stringRes(R.string.media_compression_quality_high) @@ -285,8 +259,8 @@ private fun ImageVideoPostChat( } Slider( - value = postViewModel.mediaQualitySlider.toFloat(), - onValueChange = { postViewModel.mediaQualitySlider = it.toInt() }, + value = fileUploadState.mediaQualitySlider.toFloat(), + onValueChange = { fileUploadState.mediaQualitySlider = it.toInt() }, valueRange = 0f..3f, steps = 2, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadState.kt new file mode 100644 index 0000000000..9bc20bb7d9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadState.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +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 kotlinx.collections.immutable.ImmutableList + +@Stable +class ChatFileUploadState( + val defaultServer: ServerName, +) { + var isUploadingImage by mutableStateOf(false) + + var selectedServer by mutableStateOf(defaultServer) + var caption by mutableStateOf("") + var sensitiveContent by mutableStateOf(false) + + // Images and Videos + var multiOrchestrator by mutableStateOf(null) + + // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED + var mediaQualitySlider by mutableIntStateOf(1) + + fun load(uris: ImmutableList) { + reset() + this.multiOrchestrator = MultiOrchestrator(uris) + } + + fun isImage( + url: String, + mimeType: String?, + ): Boolean = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url) + + fun reset() { + multiOrchestrator = null + isUploadingImage = false + caption = "" + selectedServer = defaultServer + } + + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + multiOrchestrator?.remove(selected) + } + + fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null + + fun hasPickedMedia() = multiOrchestrator != null +} 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 new file mode 100644 index 0000000000..f70c67e5a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -0,0 +1,167 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ChatFileUploader( + val chatroom: ChatroomKey, + val account: Account, +) { + fun uploadNIP17( + viewState: ChatFileUploadState, + scope: CoroutineScope, + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: () -> Unit, + ) { + val orchestrator = viewState.multiOrchestrator ?: return + + scope.launch(Dispatchers.Default) { + viewState.isUploadingImage = true + + val cipher = AESGCM() + + val results = + orchestrator.uploadEncrypted( + scope, + viewState.caption, + if (viewState.sensitiveContent) "" else null, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + cipher, + viewState.selectedServer, + account, + context, + ) + + if (results.allGood) { + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val template = + ChatMessageEncryptedFileHeaderEvent.build( + url = state.result.url, + to = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() }, + cipher = cipher, + mimeType = state.result.mimeTypeBeforeEncryption, + originalHash = state.result.hashBeforeEncryption, + hash = state.result.fileHeader.hash, + size = state.result.fileHeader.size, + dimension = state.result.fileHeader.dim, + blurhash = + state.result.fileHeader.blurHash + ?.blurhash, + ) { + if (viewState.caption.isNotEmpty()) { + alt(viewState.caption) + } + + if (viewState.sensitiveContent) { + contentWarning("") + } + } + + account.sendNIP17EncryptedFile(template) + } + } + + onceUploaded() + 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 + } + } + + fun uploadNIP04( + viewState: ChatFileUploadState, + scope: CoroutineScope, + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: () -> Unit, + ) { + val orchestrator = viewState.multiOrchestrator ?: return + + scope.launch(Dispatchers.Default) { + viewState.isUploadingImage = true + + val results = + orchestrator.upload( + scope, + viewState.caption, + if (viewState.sensitiveContent) "" else null, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + viewState.selectedServer, + account, + context, + ) + + if (results.allGood) { + results.successful.forEach { + if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMetaAttachments = IMetaAttachments() + iMetaAttachments.add(it.result, viewState.caption, if (viewState.sensitiveContent) "" else null) + + account.sendPrivateMessage( + message = it.result.url, + toUser = chatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }, + replyingTo = null, + zapReceiver = null, + contentWarningReason = null, + zapRaiserAmount = null, + geohash = null, + imetas = iMetaAttachments.iMetaAttachments, + emojis = null, + draftTag = null, + ) + } + + onceUploaded() + 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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/public/ChannelScreen.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/public/ChannelScreen.kt index 9951132943..b0a4faf2ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/public/ChannelScreen.kt @@ -18,50 +18,34 @@ * AN ACTION OF CONTRACT, TORT OR 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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.LocalTextSelectionColors -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -71,25 +55,19 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -120,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.INav @@ -141,6 +120,9 @@ import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.stringRes @@ -165,19 +147,30 @@ import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.ui.theme.liveStreamTag import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.experimental.audio.Participant +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent.Companion.STATUS_LIVE +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip28PublicChat.base.notify +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.notify +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import com.vitorpamplona.quartz.nip92IMeta.imetas import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.receiveAsFlow @@ -225,7 +218,6 @@ fun Channel( } } -@OptIn(FlowPreview::class) @Composable fun PrepareChannelViewModels( baseChannel: Channel, @@ -263,8 +255,6 @@ fun ChannelScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val context = LocalContext.current - NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) val lifeCycleOwner = LocalLifecycleOwner.current @@ -403,75 +393,89 @@ private suspend fun innerSendPost( val usedAttachments = newPostModel.iMetaAttachments.filter { it.url in urls.toSet() } val emojis = newPostModel.findEmoji(newPostModel.message.text, accountViewModel.account.myEmojis.value) + val channelRelays = channel.relays() + if (channel is PublicChatChannel) { - accountViewModel.account.sendChannelMessage( - message = tagger.message, - toChannel = channel.idHex, - replyTo = tagger.eTags, - mentions = tagger.pTags, - directMentions = tagger.directMentions, - wantsToMarkAsSensitive = false, - imetas = usedAttachments, - emojis = emojis, - draftTag = draftTag, - ) - } else if (channel is LiveActivitiesChannel) { - accountViewModel.account.sendLiveMessage( - message = tagger.message, - toChannel = channel.address, - replyTo = tagger.eTags, - mentions = tagger.pTags, - wantsToMarkAsSensitive = false, - imetas = usedAttachments, - emojis = emojis, - draftTag = draftTag, - ) - } -} + val replyingToEvent = replyTo.value?.toEventHint() + val channelEvent = channel.event -@Composable -fun DisplayReplyingToNote( - replyingNote: Note?, - accountViewModel: AccountViewModel, - nav: INav, - onCancel: () -> Unit, -) { - Row( - Modifier - .padding(horizontal = 10.dp) - .heightIn(max = 100.dp) - .verticalScroll(rememberScrollState()) - .animateContentSize(), - ) { - if (replyingNote != null) { - Column(remember { Modifier.weight(1f) }) { - ChatroomMessageCompose( - baseNote = replyingNote, - null, - innerQuote = true, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = {}, - onWantsToEditDraft = {}, - ) - } + val template = + if (replyingToEvent != null) { + ChannelMessageEvent.reply(tagger.message, replyingToEvent) { + notify(replyingToEvent.toPTag()) - Column(Modifier.padding(start = 5.dp)) { - IconButton( - modifier = Modifier.size(20.dp), - onClick = onCancel, - ) { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = - Modifier - .size(20.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) + } + } else if (channelEvent != null) { + val hint = EventHintBundle(channelEvent, channelRelays.firstOrNull()) + ChannelMessageEvent.message(tagger.message, hint) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) + } + } else { + ChannelMessageEvent.message(tagger.message, ETag(channel.idHex, channelRelays.firstOrNull())) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) } } - } + + val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) + + accountViewModel.account.signAndSendWithList(draftTag, template, channelRelays, broadcast) + } else if (channel is LiveActivitiesChannel) { + val replyingToEvent = replyTo.value?.toEventHint() + val activity = channel.info + + val template = + if (replyingToEvent != null) { + LiveActivitiesChatMessageEvent.reply(tagger.message, replyingToEvent) { + notify(replyingToEvent.toPTag()) + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) + } + } else if (activity != null) { + val hint = EventHintBundle(activity, channelRelays.firstOrNull() ?: replyingToEvent?.relay) + + LiveActivitiesChatMessageEvent.message(tagger.message, hint) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) + } + } else { + LiveActivitiesChatMessageEvent.message(tagger.message, channel.toATag()) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) + } + } + + val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) + + accountViewModel.account.signAndSendWithList(draftTag, template, channelRelays, broadcast) } } @@ -499,7 +503,7 @@ fun EditFieldRow( accountViewModel, ) - MyTextField( + ThinPaddingTextField( value = channelScreenModel.message, onValueChange = { channelScreenModel.updateMessage(it) }, keyboardOptions = @@ -533,7 +537,7 @@ fun EditFieldRow( channelScreenModel.selectImage(it) channelScreenModel.upload( alt = null, - sensitiveContent = false, + contentWarningReason = null, // Use MEDIUM quality mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM), server = accountViewModel.account.settings.defaultFileServer, @@ -552,113 +556,6 @@ fun EditFieldRow( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MyTextField( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - readOnly: Boolean = false, - textStyle: TextStyle = LocalTextStyle.current, - label: @Composable (() -> Unit)? = null, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - prefix: @Composable (() -> Unit)? = null, - suffix: @Composable (() -> Unit)? = null, - supportingText: @Composable (() -> Unit)? = null, - isError: Boolean = false, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - singleLine: Boolean = false, - maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, - minLines: Int = 1, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - shape: Shape = TextFieldDefaults.shape, - colors: TextFieldColors = TextFieldDefaults.colors(), - contentPadding: PaddingValues = - if (label == null) { - TextFieldDefaults.contentPaddingWithoutLabel( - start = 10.dp, - top = 12.dp, - end = 10.dp, - bottom = 12.dp, - ) - } else { - TextFieldDefaults.contentPaddingWithLabel( - start = 10.dp, - top = 12.dp, - end = 10.dp, - bottom = 12.dp, - ) - }, -) { - // COPIED FROM TEXT FIELD - // The only change is the contentPadding below - val textColor = - textStyle.color.takeOrElse { - val focused by interactionSource.collectIsFocusedAsState() - - val targetValue = - when { - !enabled -> MaterialTheme.colorScheme.placeholderText - isError -> MaterialTheme.colorScheme.onSurface - focused -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurface - } - - rememberUpdatedState(targetValue).value - } - val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) - - CompositionLocalProvider(LocalTextSelectionColors provides LocalTextSelectionColors.current) { - BasicTextField( - value = value, - modifier = - modifier.defaultMinSize( - minWidth = TextFieldDefaults.MinWidth, - minHeight = 36.dp, - ), - onValueChange = onValueChange, - enabled = enabled, - readOnly = readOnly, - textStyle = mergedTextStyle, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - interactionSource = interactionSource, - singleLine = singleLine, - maxLines = maxLines, - minLines = minLines, - decorationBox = - @Composable { innerTextField -> - TextFieldDefaults.DecorationBox( - value = value.text, - visualTransformation = visualTransformation, - innerTextField = innerTextField, - placeholder = placeholder, - label = label, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - prefix = prefix, - suffix = suffix, - supportingText = supportingText, - shape = shape, - singleLine = singleLine, - enabled = enabled, - isError = isError, - interactionSource = interactionSource, - colors = colors, - contentPadding = contentPadding, - ) - }, - ) - } -} - @Composable fun RenderChannelHeader( channelNote: Note, @@ -805,7 +702,7 @@ fun ShowVideoStreaming( description = baseChannel.toBestDisplayName(), artworkUri = event.image(), authorName = baseChannel.creatorName(), - uri = event.toNostrUri(), + uri = baseChannel.toNAddr(), ) } @@ -988,21 +885,20 @@ fun LongChannelHeader( } } - var participantUsers by - remember(baseChannel) { - mutableStateOf>>( + if (channel is LiveActivitiesChannel) { + var participantUsers by remember(baseChannel) { + mutableStateOf>>( persistentListOf(), ) } - if (channel is LiveActivitiesChannel) { LaunchedEffect(key1 = channelState) { launch(Dispatchers.IO) { val newParticipantUsers = channel.info ?.participants() ?.mapNotNull { part -> - LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } + LocalCache.checkGetOrCreateUser(part.pubKey)?.let { Pair(part, it) } }?.toImmutableList() if ( @@ -1135,7 +1031,7 @@ private fun LiveChannelActionOptions( accountViewModel: AccountViewModel, nav: INav, ) { - val isLive by remember(channel) { derivedStateOf { channel.info?.status() == STATUS_LIVE } } + val isLive by remember(channel) { derivedStateOf { channel.info?.status() == StatusTag.STATUS.LIVE.code } } val note = remember(channel.idHex) { LocalCache.getNoteIfExists(channel.idHex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt new file mode 100644 index 0000000000..ad4749b338 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.messages.ChatroomMessageCompose +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun DisplayReplyingToNote( + replyingNote: Note?, + accountViewModel: AccountViewModel, + nav: INav, + onCancel: () -> Unit, +) { + Row( + Modifier + .padding(horizontal = 10.dp) + .heightIn(max = 100.dp) + .verticalScroll(rememberScrollState()) + .animateContentSize(), + ) { + if (replyingNote != null) { + Column(remember { Modifier.weight(1f) }) { + ChatroomMessageCompose( + baseNote = replyingNote, + null, + innerQuote = true, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + } + + Column(Modifier.padding(start = 5.dp)) { + IconButton( + modifier = Size20Modifier, + onClick = onCancel, + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt new file mode 100644 index 0000000000..bc8a45e6de --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier + +@Composable +fun ThinSendButton( + isActive: Boolean, + modifier: Modifier, + onClick: () -> Unit, +) { + IconButton( + enabled = isActive, + modifier = modifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = stringRes(id = R.string.accessibility_send), + modifier = Size20Modifier, + ) + } +} 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 9ba6e2fa53..f21abbea74 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 @@ -78,10 +78,10 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList 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 c99bc0d748..d7dd79387a 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 @@ -95,8 +95,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppMetadata +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import kotlinx.collections.immutable.ImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index aa9ac23ac7..7bb7a6da65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -38,13 +38,13 @@ import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.feeds.LoadedFeedState import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.ammolite.relays.BundledUpdate -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip17Dm.NIP17Group +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import kotlinx.collections.immutable.ImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 2fa9ebae1f..5065442502 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -283,7 +283,7 @@ fun NoteCardCompose( ) { NoteCompose( baseNote = baseNote.note, - modifier = modifier, + modifier = modifier.fillMaxWidth(), routeForLastRead = routeForLastRead, isBoostedNote = isBoostedNote, isQuotedNote = isQuotedNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index 150691d954..22d33584a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -22,21 +22,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import android.util.Log import androidx.compose.runtime.Stable -import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel -import com.patrykandpatrick.vico.core.entry.ChartEntryModel -import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer -import com.patrykandpatrick.vico.core.entry.composed.plus -import com.patrykandpatrick.vico.core.entry.entryOf +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel +import com.patrykandpatrick.vico.core.common.data.ExtraStore +import com.patrykandpatrick.vico.core.common.data.MutableExtraStore import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.ammolite.relays.BundledInsert -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -52,37 +52,30 @@ import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter +val ShowDecimals = ExtraStore.Key() +val BottomAxisLabelKey = ExtraStore.Key>() + @Stable class NotificationSummaryState( val account: Account, ) { val user: User = account.userProfile() - private var _reactions = MutableStateFlow>(emptyMap()) - private var _boosts = MutableStateFlow>(emptyMap()) - private var _zaps = MutableStateFlow>(emptyMap()) - private var _replies = MutableStateFlow>(emptyMap()) - - private var _chartModel = MutableStateFlow?>(null) - private var _axisLabels = MutableStateFlow>(emptyList()) - - val reactions = _reactions.asStateFlow() - val boosts = _boosts.asStateFlow() - val zaps = _zaps.asStateFlow() - val replies = _replies.asStateFlow() + private var reactions = MutableStateFlow>(emptyMap()) + private var boosts = MutableStateFlow>(emptyMap()) + private var zaps = MutableStateFlow>(emptyMap()) + private var replies = MutableStateFlow>(emptyMap()) + private var _chartModel = MutableStateFlow(null) val chartModel = _chartModel.asStateFlow() - val axisLabels = _axisLabels.asStateFlow() private var takenIntoAccount = setOf() private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() - val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged() - - var shouldShowDecimalsInAxis = false + val todaysReplyCount = replies.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysBoostCount = boosts.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysReactionCount = reactions.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysZapAmount = zaps.map { showAmountInteger(it[today()]) }.distinctUntilChanged() fun formatDate(createAt: Long): String = sdf.format( @@ -126,7 +119,7 @@ class NotificationSummaryState( (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) takenIntoAccount.add(noteEvent.id) } - } else if (noteEvent is BaseTextNoteEvent) { + } else if (noteEvent is BaseThreadedEvent) { if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { val isCitation = noteEvent.findCitations().any { @@ -146,10 +139,10 @@ class NotificationSummaryState( } this.takenIntoAccount = takenIntoAccount - this._reactions.emit(reactions) - this._replies.emit(replies) - this._zaps.emit(zaps) - this._boosts.emit(boosts) + this.reactions.emit(reactions) + this.replies.emit(replies) + this.zaps.emit(zaps) + this.boosts.emit(boosts) refreshChartModel() } @@ -159,10 +152,10 @@ class NotificationSummaryState( val currentUser = user.pubkeyHex - val reactions = this._reactions.value.toMutableMap() - val boosts = this._boosts.value.toMutableMap() - val zaps = this._zaps.value.toMutableMap() - val replies = this._replies.value.toMutableMap() + val reactions = this.reactions.value.toMutableMap() + val boosts = this.boosts.value.toMutableMap() + val zaps = this.zaps.value.toMutableMap() + val replies = this.replies.value.toMutableMap() val takenIntoAccount = this.takenIntoAccount.toMutableSet() var hasNewElements = false @@ -194,7 +187,7 @@ class NotificationSummaryState( takenIntoAccount.add(noteEvent.id) hasNewElements = true } - } else if (noteEvent is BaseTextNoteEvent) { + } else if (noteEvent is BaseThreadedEvent) { if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { val isCitation = noteEvent.findCitations().any { @@ -217,10 +210,10 @@ class NotificationSummaryState( if (hasNewElements) { this.takenIntoAccount = takenIntoAccount - this._reactions.emit(reactions) - this._replies.emit(replies) - this._zaps.emit(zaps) - this._boosts.emit(boosts) + this.reactions.emit(reactions) + this.replies.emit(replies) + this.zaps.emit(zaps) + this.boosts.emit(boosts) refreshChartModel() } @@ -229,59 +222,42 @@ class NotificationSummaryState( private suspend fun refreshChartModel() { checkNotInMainThread() - val day = 24 * 60 * 60L val now = LocalDateTime.now() - val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") - val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) } + val dataAxisLabelIndexes = listOf(-6, -5, -4, -3, -2, -1, 0) + val dataAxisLabels = dataAxisLabelIndexes.map { sdf.format(now.plusDays(it.toLong())) } - val listOfCountCurves = - listOf( - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _replies.value[dateStr]?.toFloat() ?: 0f) - }, - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) - }, - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) - }, - ) - - val listOfValueCurves = - listOf( - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) - }, - ) - - val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() - val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() - - chartEntryModelProducer1?.let { chart1 -> - chartEntryModelProducer2?.let { chart2 -> - this.shouldShowDecimalsInAxis = shouldShowDecimals(chart2.minY, chart2.maxY) - - this._axisLabels.emit( - listOf(6, 5, 4, 3, 2, 1, 0).map { - displayAxisFormatter.format(now.minusSeconds(day * it)) - }, - ) - this._chartModel.emit(chart1.plus(chart2)) + val chart1 = + LineCartesianLayerModel.build { + series(dataAxisLabelIndexes, dataAxisLabels.map { replies.value[it]?.toFloat() ?: 0f }) + series(dataAxisLabelIndexes, dataAxisLabels.map { boosts.value[it]?.toFloat() ?: 0f }) + series(dataAxisLabelIndexes, dataAxisLabels.map { reactions.value[it]?.toFloat() ?: 0f }) } - } + + val chart2 = + LineCartesianLayerModel.build { + series(dataAxisLabelIndexes, dataAxisLabels.map { zaps.value[it]?.toFloat() ?: 0f }) + } + + val model = CartesianChartModel(chart1, chart2) + + val mutableStore = MutableExtraStore() + + mutableStore[ShowDecimals] = shouldShowDecimals(chart2.minY, chart2.maxY) + + this._chartModel.emit(model.copy(mutableStore)) } // determine if the min max are so close that they render to the same number. fun shouldShowDecimals( - min: Float, - max: Float, + min: Double, + max: Double, ): Boolean { val step = (max - min) / 8 - var previous = showAmountAxis(min.toBigDecimal()) + var previous = showAmountInteger(min.toBigDecimal()) for (i in 1..7) { - val current = showAmountAxis((min + (i * step)).toBigDecimal()) + val current = showAmountInteger((min + (i * step)).toBigDecimal()) if (previous == current) { return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt index 7e16002b2c..c8b4717d0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt @@ -30,47 +30,17 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State 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 androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.patrykandpatrick.vico.compose.axis.axisLabelComponent -import com.patrykandpatrick.vico.compose.axis.horizontal.rememberBottomAxis -import com.patrykandpatrick.vico.compose.axis.vertical.rememberEndAxis -import com.patrykandpatrick.vico.compose.axis.vertical.rememberStartAxis -import com.patrykandpatrick.vico.compose.chart.Chart -import com.patrykandpatrick.vico.compose.chart.line.lineChart -import com.patrykandpatrick.vico.compose.component.shape.shader.fromBrush -import com.patrykandpatrick.vico.compose.style.ProvideChartStyle -import com.patrykandpatrick.vico.core.DefaultAlpha -import com.patrykandpatrick.vico.core.axis.AxisPosition -import com.patrykandpatrick.vico.core.axis.formatter.AxisValueFormatter -import com.patrykandpatrick.vico.core.chart.composed.plus -import com.patrykandpatrick.vico.core.chart.line.LineChart -import com.patrykandpatrick.vico.core.chart.values.ChartValues -import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders -import com.vitorpamplona.amethyst.ui.note.OneGiga -import com.vitorpamplona.amethyst.ui.note.OneKilo -import com.vitorpamplona.amethyst.ui.note.OneMega -import com.vitorpamplona.amethyst.ui.note.TenKilo +import com.patrykandpatrick.vico.compose.common.ProvideVicoTheme import com.vitorpamplona.amethyst.ui.note.UserReactionsRow -import com.vitorpamplona.amethyst.ui.note.showAmount -import com.vitorpamplona.amethyst.ui.note.showCount -import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange -import com.vitorpamplona.amethyst.ui.theme.RoyalBlue +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart.ShowChart import com.vitorpamplona.amethyst.ui.theme.chartStyle -import java.math.BigDecimal -import java.math.RoundingMode -import java.text.DecimalFormat -import kotlin.math.roundToInt @Composable fun SummaryBar(state: NotificationSummaryState) { @@ -83,137 +53,24 @@ fun SummaryBar(state: NotificationSummaryState) { enter = slideInVertically() + expandVertically(), exit = slideOutVertically() + shrinkVertically(), ) { - val lineChartCount = - lineChart( - lines = - listOf(RoyalBlue, Color.Green, Color.Red).map { lineChartColor -> - LineChart.LineSpec( - lineColor = lineChartColor.toArgb(), - lineBackgroundShader = - DynamicShaders.fromBrush( - Brush.verticalGradient( - listOf( - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), - ), - ), - ), - ) - }, - targetVerticalAxisPosition = AxisPosition.Vertical.Start, - ) - - val lineChartZaps = - lineChart( - lines = - listOf(BitcoinOrange).map { lineChartColor -> - LineChart.LineSpec( - lineColor = lineChartColor.toArgb(), - lineBackgroundShader = - DynamicShaders.fromBrush( - Brush.verticalGradient( - listOf( - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), - ), - ), - ), - ) - }, - targetVerticalAxisPosition = AxisPosition.Vertical.End, - ) - Row( modifier = Modifier .padding(vertical = 0.dp, horizontal = 20.dp) .clickable(onClick = { showChart = !showChart }), ) { - ProvideChartStyle( - chartStyle = MaterialTheme.colorScheme.chartStyle, - ) { - ObserveAndShowChart(state, lineChartCount, lineChartZaps) + ProvideVicoTheme(MaterialTheme.colorScheme.chartStyle) { + ObserveAndShowChart(state) } } } } @Composable -private fun ObserveAndShowChart( - state: NotificationSummaryState, - lineChartCount: LineChart, - lineChartZaps: LineChart, -) { - val axisModel = state.axisLabels.collectAsStateWithLifecycle() +private fun ObserveAndShowChart(state: NotificationSummaryState) { val chartModel by state.chartModel.collectAsStateWithLifecycle() chartModel?.let { - Chart( - chart = remember(lineChartCount, lineChartZaps) { lineChartCount.plus(lineChartZaps) }, - model = it, - startAxis = - rememberStartAxis( - valueFormatter = CountAxisValueFormatter(), - ), - endAxis = - rememberEndAxis( - label = axisLabelComponent(color = BitcoinOrange), - valueFormatter = AmountAxisValueFormatter(state.shouldShowDecimalsInAxis), - ), - bottomAxis = - rememberBottomAxis( - valueFormatter = LabelValueFormatter(axisModel), - ), - ) - } -} - -@Stable -class LabelValueFormatter( - val axisLabels: State>, -) : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues, - ): String = axisLabels.value[value.roundToInt()] -} - -@Stable -class CountAxisValueFormatter : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues, - ): String = showCount(value.roundToInt()) -} - -@Stable -class AmountAxisValueFormatter( - val showDecimals: Boolean, -) : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues, - ): String = - if (showDecimals) { - showAmount(value.toBigDecimal()) - } else { - showAmountAxis(value.toBigDecimal()) - } -} - -var dfG: DecimalFormat = DecimalFormat("#G") -var dfM: DecimalFormat = DecimalFormat("#M") -var dfK: DecimalFormat = DecimalFormat("#k") -var dfN: DecimalFormat = DecimalFormat("#") - -fun showAmountAxis(amount: BigDecimal?): String { - if (amount == null) return "" - if (amount.abs() < BigDecimal(0.01)) return "" - - return when { - amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.format(amount) + ShowChart(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt new file mode 100644 index 0000000000..c1b0a79b0e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart + +import androidx.compose.runtime.Stable +import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.core.cartesian.axis.Axis +import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.vitorpamplona.amethyst.ui.note.showAmountIntegerWithZero +import com.vitorpamplona.amethyst.ui.note.showAmountWithZero +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ShowDecimals + +@Stable +class AmountValueFormatter : CartesianValueFormatter { + override fun format( + context: CartesianMeasuringContext, + value: Double, + verticalAxisPosition: Axis.Position.Vertical?, + ): CharSequence = + if (context.model.extraStore[ShowDecimals]) { + showAmountWithZero(value.toBigDecimal()) + } else { + showAmountIntegerWithZero(value.toBigDecimal()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt new file mode 100644 index 0000000000..1f64239b76 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart + +import androidx.compose.runtime.Stable +import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.core.cartesian.axis.Axis +import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import kotlin.math.roundToInt + +@Stable +class CountAxisValueFormatter : CartesianValueFormatter { + private fun showCountChart(count: Int?): String { + if (count == null) return "0" + + return when { + count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G" + count >= 1000000 -> "${(count / 1000000f).roundToInt()}M" + count >= 10000 -> "${(count / 1000f).roundToInt()}k" + else -> "$count" + } + } + + override fun format( + context: CartesianMeasuringContext, + value: Double, + verticalAxisPosition: Axis.Position.Vertical?, + ): CharSequence = showCountChart(value.roundToInt()) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt new file mode 100644 index 0000000000..d9e44e0158 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart + +import android.util.LruCache +import androidx.compose.runtime.Stable +import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.core.cartesian.axis.Axis +import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.roundToInt + +@Stable +class LastWeekLabelFormatter : CartesianValueFormatter { + private val displayAxisFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE") + private val now = LocalDateTime.now() + + private val cache = LruCache(10) + + override fun format( + context: CartesianMeasuringContext, + value: Double, + verticalAxisPosition: Axis.Position.Vertical?, + ): CharSequence { + val key = value.roundToInt() + cache[key]?.let { return it } + + val text = displayAxisFormatter.format(now.plusDays(key.toLong())) + cache.put(key, text) + return text + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt new file mode 100644 index 0000000000..d3a98858f0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberEnd +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.common.fill +import com.patrykandpatrick.vico.core.cartesian.axis.Axis +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.core.common.shader.ShaderProvider +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.RoyalBlue + +fun makeLine(color: Color): LineCartesianLayer.Line = + LineCartesianLayer.Line( + fill = LineCartesianLayer.LineFill.single(fill(color)), + areaFill = + LineCartesianLayer.AreaFill.single( + fill( + ShaderProvider.verticalGradient( + color.copy(alpha = 0.4f).toArgb(), + Color.Transparent.toArgb(), + ), + ), + ), + pointConnector = LineCartesianLayer.PointConnector.cubic(), + ) + +val chartLayers = + arrayOf( + LineCartesianLayer( + LineCartesianLayer.LineProvider.series( + makeLine(RoyalBlue), + makeLine(Color.Green), + makeLine(Color.Red), + ), + verticalAxisPosition = Axis.Position.Vertical.Start, + ), + LineCartesianLayer( + LineCartesianLayer.LineProvider.series( + makeLine(BitcoinOrange), + ), + verticalAxisPosition = Axis.Position.Vertical.End, + ), + ) + +@Composable +fun ShowChart(model: CartesianChartModel) { + val chart = + rememberCartesianChart( + layers = chartLayers, + startAxis = + VerticalAxis.rememberStart( + valueFormatter = CountAxisValueFormatter(), + itemPlacer = VerticalAxis.ItemPlacer.count({ 7 }), + ), + endAxis = + VerticalAxis.rememberEnd( + label = rememberAxisLabelComponent(color = BitcoinOrange), + valueFormatter = AmountValueFormatter(), + itemPlacer = VerticalAxis.ItemPlacer.count({ 7 }), + ), + bottomAxis = + HorizontalAxis.rememberBottom( + valueFormatter = LastWeekLabelFormatter(), + ), + ) + + CartesianChartHost( + chart = chart, + model = model, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/FollowButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/FollowButtons.kt new file mode 100644 index 0000000000..70a5f3d9ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/FollowButtons.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.ButtonPadding + +@Composable +fun FollowButton( + text: Int = R.string.follow, + onClick: () -> Unit, +) { + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(text), color = Color.White, textAlign = TextAlign.Center) + } +} + +@Composable +fun UnfollowButton(onClick: () -> Unit) { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.unfollow), color = Color.White) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 3f3dd0761b..1335d383db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -20,203 +20,89 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile -import android.content.Intent -import android.util.Log -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.CutCornerShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.PlaylistAddCheck -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.ArrowDropUp -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.EditNote -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.PopupProperties -import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.actions.InformationDialog -import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji -import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus -import com.vitorpamplona.amethyst.ui.components.InvoiceRequestCard -import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage -import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter -import com.vitorpamplona.amethyst.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage -import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.DrawPlayName -import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog -import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon -import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.note.externalLinkForUser -import com.vitorpamplona.amethyst.ui.note.payViaIntent -import com.vitorpamplona.amethyst.ui.screen.NostrUserAppRecommendationsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowersUserFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowsUserFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileGalleryFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileNewThreadsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileReportFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView -import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView -import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState -import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.RenderGalleryFeed -import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.BookmarkTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.NostrUserProfileBookmarksFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.TabBookmarks +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.NostrUserProfileConversationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.TabNotesConversations +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.FollowersTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.NostrUserProfileFollowersUserFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.TabFollowers +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.FollowTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.NostrUserProfileFollowsUserFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.TabFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.NostrUserProfileGalleryFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.TabGallery +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.FollowedTagsTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.TabFollowedTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.ProfileHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.NostrUserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.NostrUserProfileMutualFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.TabMutualConversations +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.NostrUserProfileNewThreadsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.RelaysTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.TabRelays +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.NostrUserProfileReportFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.ReportsTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.TabReports +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.NostrUserProfileZapsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.TabReceivedZaps +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapTabHeader import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.LeftHalfCircleButtonBorder -import com.vitorpamplona.amethyst.ui.theme.Size100dp -import com.vitorpamplona.amethyst.ui.theme.Size15Modifier -import com.vitorpamplona.amethyst.ui.theme.Size16Modifier -import com.vitorpamplona.amethyst.ui.theme.Size25Modifier -import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.ZeroPadding -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.theme.userProfileBorderModifier -import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents -import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList -import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity -import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaim -import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity -import com.vitorpamplona.quartz.nip39ExtIdentities.TelegramIdentity -import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity -import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.math.BigDecimal @Composable fun ProfileScreen( @@ -323,6 +209,16 @@ fun PrepareViewModels( ), ) + val mutualViewModel: NostrUserProfileMutualFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileMutualFeedViewModel", + factory = + NostrUserProfileMutualFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) + val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel", @@ -346,6 +242,7 @@ fun PrepareViewModels( baseUser = baseUser, threadsViewModel, repliesViewModel, + mutualViewModel, followsFeedViewModel, followersFeedViewModel, appRecommendations, @@ -363,6 +260,7 @@ fun ProfileScreen( baseUser: User, threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, repliesViewModel: NostrUserProfileConversationsFeedViewModel, + mutualViewModel: NostrUserProfileMutualFeedViewModel, followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, appRecommendations: NostrUserAppRecommendationsFeedViewModel, @@ -404,37 +302,29 @@ fun ProfileScreen( onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - RenderSurface( - baseUser, - threadsViewModel, - repliesViewModel, - appRecommendations, - followsFeedViewModel, - followersFeedViewModel, - zapFeedViewModel, - bookmarksFeedViewModel, - galleryFeedViewModel, - reportsFeedViewModel, - accountViewModel, - nav, - ) + RenderSurface { tabRowModifier: Modifier, pagerModifier: Modifier -> + RenderScreen( + baseUser, + tabRowModifier, + pagerModifier, + threadsViewModel, + repliesViewModel, + mutualViewModel, + appRecommendations, + followsFeedViewModel, + followersFeedViewModel, + zapFeedViewModel, + bookmarksFeedViewModel, + galleryFeedViewModel, + reportsFeedViewModel, + accountViewModel, + nav, + ) + } } @Composable -private fun RenderSurface( - baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { +private fun RenderSurface(content: @Composable (tabRowModifier: Modifier, pagerModifier: Modifier) -> Unit) { Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background, @@ -488,13 +378,8 @@ private fun RenderSurface( Offset.Zero } else { // move to the max - val newY = - (borderLimit - scrollState.value).toFloat() - coroutineScope.launch { - scrollState.scrollBy( - newY, - ) - } + val newY = (borderLimit - scrollState.value).toFloat() + coroutineScope.launch { scrollState.scrollBy(newY) } Offset(0f, -newY) } } @@ -504,35 +389,20 @@ private fun RenderSurface( ).fillMaxHeight() }, ) { - RenderScreen( - baseUser, - tabRowModifier, - pagerModifier, - threadsViewModel, - repliesViewModel, - appRecommendations, - followsFeedViewModel, - followersFeedViewModel, - zapFeedViewModel, - bookmarksFeedViewModel, - galleryFeedViewModel, - reportsFeedViewModel, - accountViewModel, - nav, - ) + content(tabRowModifier, pagerModifier) } } } } @Composable -@OptIn(ExperimentalFoundationApi::class) private fun RenderScreen( baseUser: User, tabRowModifier: Modifier, pagerModifier: Modifier, threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, repliesViewModel: NostrUserProfileConversationsFeedViewModel, + mutualViewModel: NostrUserProfileMutualFeedViewModel, appRecommendations: NostrUserAppRecommendationsFeedViewModel, followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, @@ -543,7 +413,7 @@ private fun RenderScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 10 } + val pagerState = rememberPagerState { 11 } Column { ProfileHeader(baseUser, appRecommendations, nav, accountViewModel) @@ -566,6 +436,7 @@ private fun RenderScreen( baseUser, threadsViewModel, repliesViewModel, + mutualViewModel, followsFeedViewModel, followersFeedViewModel, zapFeedViewModel, @@ -585,6 +456,7 @@ private fun CreateAndRenderPages( baseUser: User, threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, repliesViewModel: NostrUserProfileConversationsFeedViewModel, + mutualViewModel: NostrUserProfileMutualFeedViewModel, followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, @@ -604,14 +476,15 @@ private fun CreateAndRenderPages( when (page) { 0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav) 1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav) - 2 -> TabGallery(galleryFeedViewModel, accountViewModel, nav) - 3 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) - 4 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) - 5 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) - 6 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) - 7 -> TabFollowedTags(baseUser, accountViewModel, nav) - 8 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) - 9 -> TabRelays(baseUser, accountViewModel, nav) + 2 -> TabMutualConversations(mutualViewModel, accountViewModel, nav) + 3 -> TabGallery(galleryFeedViewModel, accountViewModel, nav) + 4 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) + 5 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) + 6 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) + 7 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) + 8 -> TabFollowedTags(baseUser, accountViewModel, nav) + 9 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) + 10 -> TabRelays(baseUser, accountViewModel, nav) } } @@ -634,7 +507,6 @@ fun UpdateThreadsAndRepliesWhenBlockUnblock( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun CreateAndRenderTabs( baseUser: User, @@ -646,6 +518,7 @@ private fun CreateAndRenderTabs( listOf<@Composable (() -> Unit)?>( { Text(text = stringRes(R.string.notes)) }, { Text(text = stringRes(R.string.replies)) }, + { Text(text = stringRes(R.string.mutual)) }, { Text(text = stringRes(R.string.gallery)) }, { FollowTabHeader(baseUser) }, { FollowersTabHeader(baseUser) }, @@ -664,1665 +537,3 @@ private fun CreateAndRenderTabs( ) } } - -@Composable -private fun RelaysTabHeader(baseUser: User) { - val userState by baseUser.live().relays.observeAsState() - val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" } - - val userStateRelayInfo by baseUser.live().relayInfo.observeAsState() - val userRelays = - remember(userStateRelayInfo) { - userStateRelayInfo - ?.user - ?.latestContactList - ?.relays() - ?.size ?: "--" - } - - Text(text = "$userRelaysBeingUsed / $userRelays ${stringRes(R.string.relays)}") -} - -@Composable -private fun ReportsTabHeader(baseUser: User) { - val userState by baseUser.live().reports.observeAsState() - var userReports by remember { mutableIntStateOf(0) } - - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newSize = UserProfileReportsFeedFilter(baseUser).feed().size - - if (newSize != userReports) { - userReports = newSize - } - } - } - - Text(text = "$userReports ${stringRes(R.string.reports)}") -} - -@Composable -private fun FollowedTagsTabHeader(baseUser: User) { - val userState by baseUser.live().follows.observeAsState() - - val usertags by remember(baseUser) { - derivedStateOf { - userState?.user?.latestContactList?.countFollowTags() ?: 0 - } - } - - Text(text = "$usertags ${stringRes(R.string.followed_tags)}") -} - -@Composable -private fun BookmarkTabHeader(baseUser: User) { - val userState by baseUser.live().bookmarks.observeAsState() - - var userBookmarks by remember { mutableIntStateOf(0) } - - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val bookmarkList = userState?.user?.latestBookmarkList - - val newBookmarks = - ( - bookmarkList?.taggedEvents()?.count() - ?: 0 - ) + (bookmarkList?.taggedAddresses()?.count() ?: 0) - - if (newBookmarks != userBookmarks) { - userBookmarks = newBookmarks - } - } - } - - Text(text = "$userBookmarks ${stringRes(R.string.bookmarks)}") -} - -@Composable -private fun ZapTabHeader(baseUser: User) { - val userState by baseUser.live().zaps.observeAsState() - var zapAmount by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = userState) { - launch(Dispatchers.Default) { - val tempAmount = baseUser.zappedAmount() - if (zapAmount != tempAmount) { - zapAmount = tempAmount - } - } - } - - Text(text = "${showAmountAxis(zapAmount)} ${stringRes(id = R.string.zaps)}") -} - -@Composable -private fun FollowersTabHeader(baseUser: User) { - val userState by baseUser.live().followers.observeAsState() - var followerCount by remember { mutableStateOf("--") } - - val text = stringRes(R.string.followers) - - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newFollower = (userState?.user?.transientFollowerCount()?.toString() ?: "--") + " " + text - - if (followerCount != newFollower) { - followerCount = newFollower - } - } - } - - Text(text = followerCount) -} - -@Composable -private fun FollowTabHeader(baseUser: User) { - val userState by baseUser.live().follows.observeAsState() - var followCount by remember { mutableStateOf("--") } - - val text = stringRes(R.string.follows) - - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newFollow = (userState?.user?.transientFollowCount()?.toString() ?: "--") + " " + text - - if (followCount != newFollow) { - followCount = newFollow - } - } - } - - Text(text = followCount) -} - -@Composable -private fun ProfileHeader( - baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - nav: INav, - accountViewModel: AccountViewModel, -) { - var popupExpanded by remember { mutableStateOf(false) } - var zoomImageDialogOpen by remember { mutableStateOf(false) } - - Box { - DrawBanner(baseUser, accountViewModel) - - Box( - modifier = - Modifier - .statusBarsPadding() - .padding(start = 10.dp, end = 10.dp, top = 10.dp) - .size(40.dp) - .align(Alignment.TopEnd), - ) { - Button( - modifier = - Modifier - .size(30.dp) - .align(Alignment.Center), - onClick = { popupExpanded = true }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.background, - ), - contentPadding = ZeroPadding, - ) { - Icon( - tint = MaterialTheme.colorScheme.placeholderText, - imageVector = Icons.Default.MoreVert, - contentDescription = stringRes(R.string.more_options), - ) - - UserProfileDropDownMenu( - baseUser, - popupExpanded, - { popupExpanded = false }, - accountViewModel, - ) - } - } - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp) - .padding(top = 100.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, - ) { - val clipboardManager = LocalClipboardManager.current - - ClickableUserPicture( - baseUser = baseUser, - accountViewModel = accountViewModel, - size = Size100dp, - modifier = MaterialTheme.colorScheme.userProfileBorderModifier, - onClick = { - if (baseUser.profilePicture() != null) { - zoomImageDialogOpen = true - } - }, - onLongClick = { - it.info?.picture?.let { it1 -> - clipboardManager.setText( - AnnotatedString(it1), - ) - } - }, - ) - - Spacer(Modifier.weight(1f)) - - Row( - modifier = - Modifier - .height(Size35dp) - .padding(bottom = 3.dp), - ) { - MessageButton(baseUser, accountViewModel, nav) - - ProfileActions(baseUser, accountViewModel, nav) - } - } - - DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) - - HorizontalDivider(modifier = Modifier.padding(top = 6.dp)) - } - } - - val profilePic = baseUser.profilePicture() - if (zoomImageDialogOpen && profilePic != null) { - ZoomableImageDialog( - RichTextParser.parseImageOrVideo(profilePic), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) - } -} - -@Composable -private fun ProfileActions( - baseUser: User, - accountViewModel: AccountViewModel, - nav: INav, -) { - val tempFollowLists = remember { generateFollowLists().toMutableStateList() } - - val isMe by - remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } - - if (isMe) { - EditButton(nav) - } - - WatchIsHiddenUser(baseUser, accountViewModel) { isHidden -> - if (isHidden) { - ShowUserButton { accountViewModel.showUser(baseUser.pubkeyHex) } - } else { - DisplayFollowUnfollowButton(baseUser, accountViewModel) - } - FollowSetsActionMenu( - userHex = baseUser.pubkeyHex, - followLists = tempFollowLists, - addUser = { index, list -> - Log.d("Amethyst", "ProfileActions: Updating list ...") - val newList = tempFollowLists[index].memberList + baseUser.pubkeyHex - tempFollowLists[index] = tempFollowLists[index].copy(memberList = newList) - println("Updated List. New size: ${tempFollowLists[index].memberList.size}") - }, - removeUser = { index -> - Log.d("Amethyst", "ProfileActions: Updating list ...") - val newList = tempFollowLists[index].memberList - baseUser.pubkeyHex - tempFollowLists[index] = tempFollowLists[index].copy(memberList = newList) - println("Updated List. New size: ${tempFollowLists[index].memberList.size}") - }, - ) - } -} - -@Composable -private fun DisplayFollowUnfollowButton( - baseUser: User, - accountViewModel: AccountViewModel, -) { - val isLoggedInFollowingUser by - accountViewModel.account - .userProfile() - .live() - .follows - .map { it.user.isFollowing(baseUser) } - .distinctUntilChanged() - .observeAsState(initial = accountViewModel.account.isFollowing(baseUser)) - - val isUserFollowingLoggedIn by - baseUser - .live() - .follows - .map { it.user.isFollowing(accountViewModel.account.userProfile()) } - .distinctUntilChanged() - .observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile())) - - if (isLoggedInFollowingUser) { - UnfollowButton( - shape = LeftHalfCircleButtonBorder, - ) { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_unfollow, - ) - } else { - accountViewModel.unfollow(baseUser) - } - } - } else { - if (isUserFollowingLoggedIn) { - FollowButton( - text = R.string.follow_back, - shape = LeftHalfCircleButtonBorder, - ) { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow, - ) - } else { - accountViewModel.follow(baseUser) - } - } - } else { - FollowButton( - text = R.string.follow, - shape = LeftHalfCircleButtonBorder, - ) { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow, - ) - } else { - accountViewModel.follow(baseUser) - } - } - } - } -// FollowSetsActionMenu() -} - -@Composable -fun WatchIsHiddenUser( - baseUser: User, - accountViewModel: AccountViewModel, - content: @Composable (Boolean) -> Unit, -) { - val isHidden by - accountViewModel.account.liveHiddenUsers - .map { - it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) - }.observeAsState(accountViewModel.account.isHidden(baseUser)) - - content(isHidden) -} - -@Composable -fun FollowSetsActionMenu( - userHex: String, - followLists: List, - modifier: Modifier = Modifier, - addUser: (followListItemIndex: Int, list: FollowInfo) -> Unit, - removeUser: (followListItemIndex: Int) -> Unit, -) { - val (isMenuOpen, setMenuValue) = remember { mutableStateOf(false) } - val uiScope = rememberCoroutineScope() - - Column { - TextButton( - onClick = { setMenuValue(true) }, - shape = ButtonBorder.copy(topStart = CornerSize(0f), bottomStart = CornerSize(0f)), - colors = - ButtonDefaults - .buttonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = ZeroPadding, - ) { - Icon( - imageVector = if (isMenuOpen) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, - contentDescription = "", - ) - } - -// Icon( -// imageVector = if (isMenuOpen.value) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, -// contentDescription = "", -// modifier = -// Modifier -// .fillMaxHeight() -// .background( -// color = MaterialTheme.colorScheme.primary, -// shape = ButtonBorder.copy(topStart = CornerSize(0f), bottomStart = CornerSize(0f)), -// ).border( -// width = Dp.Hairline, -// color = MaterialTheme.colorScheme.primary, -// shape = -// ButtonBorder -// .copy(topStart = CornerSize(0f), bottomStart = CornerSize(0f)), -// ).clickable(role = Role.DropdownList) { -// isMenuOpen.value = !isMenuOpen.value -// }, -// ) - - DropdownMenu( - expanded = isMenuOpen, - onDismissRequest = { - uiScope.launch { - delay(100L) - setMenuValue(false) - } - }, - modifier = Modifier.fillMaxWidth(), - properties = PopupProperties(usePlatformDefaultWidth = true), - ) { - DropDownMenuHeader(headerText = "Add to lists") - followLists.forEachIndexed { index, list -> - Spacer(StdVertSpacer) - DropdownMenuItem( - text = { - FollowSetItem( - modifier = Modifier.fillMaxWidth(), - listHeader = list.name, - listIsPublic = !list.isPrivate, - isUserInList = list.memberList.contains(userHex), - onRemoveUser = { - removeUser(index) - }, - onAddUser = { - println("List contains user -> ${list.memberList.contains(userHex)}") - println("Adding user to List -> ${list.name}") - addUser(index, list) - println("List contains user -> ${list.memberList.contains(userHex)}") - }, - ) - }, - onClick = {}, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } -} - -@Composable -private fun DropDownMenuHeader( - modifier: Modifier = Modifier, - headerText: String, -) { - Column { - DropdownMenuItem( - text = { - Text(text = headerText, fontWeight = FontWeight.SemiBold) - }, - onClick = {}, - enabled = false, - ) - HorizontalDivider() - } -} - -data class FollowInfo( - val name: String, - val isPrivate: Boolean, - val memberList: List = listOf(), -) - -fun generateFollowLists(): List = - List(10) { index: Int -> - FollowInfo( - name = "List No $index", - isPrivate = index % 2 == 0, - ) - } - -@Composable -fun FollowSetItem( - modifier: Modifier = Modifier, - listHeader: String, - listIsPublic: Boolean, - isUserInList: Boolean, - onAddUser: () -> Unit, - onRemoveUser: () -> Unit, -) { - Row( - modifier = - modifier -// .clickable(onClick = onAddUser) - .border( - width = Dp.Hairline, - color = Color.Gray, - shape = RoundedCornerShape(percent = 20), - ).padding(all = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = modifier.weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Text(listHeader, fontWeight = FontWeight.Bold) - Spacer(modifier = StdVertSpacer) - Row { - FilterChip( - selected = isUserInList, - enabled = isUserInList, - onClick = {}, - label = { - Text(text = if (isUserInList) "In List" else "Not in List") - }, - leadingIcon = - if (isUserInList) { - { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAddCheck, - contentDescription = null, - ) - } - } else { - null - }, - shape = ButtonBorder, - ) - Spacer(modifier = StdHorzSpacer) - AssistChip( - onClick = { - if (isUserInList) onRemoveUser() else onAddUser() - }, - label = { - Text(text = if (isUserInList) "Remove" else "Add") - }, - leadingIcon = { - if (isUserInList) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.onBackground, - ) - } else { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onBackground, - ) - } - }, - shape = ButtonBorder, - colors = - AssistChipDefaults.assistChipColors( - containerColor = - if (isUserInList) { - MaterialTheme.colorScheme.errorContainer - } else { - MaterialTheme.colorScheme.primary - }, - ), - border = - AssistChipDefaults - .assistChipBorder( - enabled = true, - borderColor = - if (!isUserInList) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.errorContainer - }, - ), - ) - } - } - - listIsPublic.let { - val text by derivedStateOf { if (!it) "Private" else "Public" } - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - painter = - painterResource( - if (!it) R.drawable.incognito else R.drawable.ic_public, - ), - contentDescription = "Icon for $text List", - ) - Text(text, color = Color.Gray) - } - } - } -} - -fun getIdentityClaimIcon(identity: IdentityClaim): Int = - when (identity) { - is TwitterIdentity -> R.drawable.x - is TelegramIdentity -> R.drawable.telegram - is MastodonIdentity -> R.drawable.mastodon - is GitHubIdentity -> R.drawable.github - else -> R.drawable.github - } - -fun getIdentityClaimDescription(identity: IdentityClaim): Int = - when (identity) { - is TwitterIdentity -> R.string.twitter - is TelegramIdentity -> R.string.telegram - is MastodonIdentity -> R.string.mastodon - is GitHubIdentity -> R.string.github - else -> R.drawable.github - } - -@Composable -private fun DrawAdditionalInfo( - baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val userState by baseUser.live().metadata.observeAsState() - val user = remember(userState) { userState?.user } ?: return - val tags = userState?.user?.info?.tags - - val uri = LocalUriHandler.current - val clipboardManager = LocalClipboardManager.current - - user.toBestDisplayName().let { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) { - CreateTextWithEmoji( - text = it, - tags = tags, - fontWeight = FontWeight.Bold, - fontSize = 25.sp, - ) - Spacer(StdHorzSpacer) - user.info?.pronouns?.let { - Text( - text = "($it)", - modifier = Modifier, - ) - Spacer(StdHorzSpacer) - } - - DrawPlayName(it) - } - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = user.pubkeyDisplayHex(), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), - color = MaterialTheme.colorScheme.placeholderText, - ) - - IconButton( - modifier = - Modifier - .size(25.dp) - .padding(start = 5.dp), - onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) }, - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = stringRes(id = R.string.copy_npub_to_clipboard), - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } - - var dialogOpen by remember { mutableStateOf(false) } - - if (dialogOpen) { - ShowQRDialog( - user = user, - accountViewModel = accountViewModel, - onScan = { - dialogOpen = false - nav.nav(it) - }, - onClose = { dialogOpen = false }, - ) - } - - IconButton( - modifier = Size25Modifier, - onClick = { dialogOpen = true }, - ) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - contentDescription = stringRes(id = R.string.show_npub_as_a_qr_code), - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } - } - - DisplayBadges(baseUser, accountViewModel, nav) - - DisplayNip05ProfileStatus(user, accountViewModel) - - val website = user.info?.website - if (!website.isNullOrEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - tint = MaterialTheme.colorScheme.placeholderText, - imageVector = Icons.Default.Link, - contentDescription = stringRes(R.string.website), - modifier = Modifier.size(16.dp), - ) - - ClickableText( - text = AnnotatedString(website.removePrefix("https://")), - onClick = { - website.let { - runCatching { - if (it.contains("://")) { - uri.openUri(it) - } else { - uri.openUri("http://$it") - } - } - } - }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), - ) - } - } - - val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } - val pubkeyHex = remember { baseUser.pubkeyHex } - DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) - - val identities = user.latestMetadata?.identityClaims() - if (!identities.isNullOrEmpty()) { - identities.forEach { identity: IdentityClaim -> - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - tint = Color.Unspecified, - painter = painterResource(id = getIdentityClaimIcon(identity)), - contentDescription = stringRes(getIdentityClaimDescription(identity)), - modifier = Modifier.size(16.dp), - ) - - ClickableText( - text = AnnotatedString(identity.identity), - onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = - Modifier - .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - .weight(1f), - ) - } - } - } - - user.info?.about?.let { - Row( - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), - ) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - - TranslatableRichTextViewer( - content = it, - canPreview = false, - quotesLeft = 1, - tags = EmptyTagList, - backgroundColor = background, - id = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - - DisplayAppRecommendations(appRecommendations, accountViewModel, nav) -} - -@Composable -fun DisplayLNAddress( - lud16: String?, - userHex: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var zapExpanded by remember { mutableStateOf(false) } - - var showErrorMessageDialog by remember { mutableStateOf(null) } - - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = stringRes(id = R.string.error_dialog_zap_error), - textContent = showErrorMessageDialog ?: "", - onClickStartMessage = { - scope.launch(Dispatchers.IO) { - val route = routeToMessage(userHex, showErrorMessageDialog, accountViewModel) - nav.nav(route) - } - }, - onDismiss = { showErrorMessageDialog = null }, - ) - } - - var showInfoMessageDialog by remember { mutableStateOf(null) } - if (showInfoMessageDialog != null) { - InformationDialog( - title = stringRes(context, R.string.payment_successful), - textContent = showInfoMessageDialog ?: "", - ) { - showInfoMessageDialog = null - } - } - - if (!lud16.isNullOrEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange) - - ClickableText( - text = AnnotatedString(lud16), - onClick = { zapExpanded = !zapExpanded }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = - Modifier - .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - .weight(1f), - ) - } - - if (zapExpanded) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), - ) { - InvoiceRequestCard( - lud16, - userHex, - accountViewModel, - onSuccess = { - zapExpanded = false - // pay directly - if (accountViewModel.account.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor(it, null, onSent = {}) { response -> - if (response is PayInvoiceSuccessResponse) { - showInfoMessageDialog = stringRes(context, R.string.payment_successful) - } else if (response is PayInvoiceErrorResponse) { - showErrorMessageDialog = - response.error?.message - ?: response.error?.code?.toString() - ?: stringRes(context, R.string.error_parsing_error_message) - } - } - } else { - payViaIntent(it, context, { zapExpanded = false }, { showErrorMessageDialog = it }) - } - }, - onClose = { zapExpanded = false }, - onError = { title, message -> accountViewModel.toast(title, message) }, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun DisplayAppRecommendations( - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val feedState by appRecommendations.feedState.feedContent.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = Unit) { appRecommendations.invalidateData() } - - CrossfadeIfEnabled( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - accountViewModel = accountViewModel, - ) { state -> - when (state) { - is FeedState.Loaded -> { - Column { - Text(stringRes(id = R.string.recommended_apps)) - - Recommends(state, nav) - } - } - else -> {} - } - } -} - -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun Recommends( - loaded: FeedState.Loaded, - nav: INav, -) { - val items by loaded.feed.collectAsStateWithLifecycle() - FlowRow( - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(vertical = 5.dp), - ) { - items.list.forEach { app -> WatchApp(app, nav) } - } -} - -@Composable -private fun WatchApp( - baseApp: Note, - nav: INav, -) { - val appState by baseApp.live().metadata.observeAsState() - - var appLogo by remember(baseApp) { mutableStateOf(null) } - var appName by remember(baseApp) { mutableStateOf(null) } - - LaunchedEffect(key1 = appState) { - withContext(Dispatchers.Default) { - (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.let { metaData -> - metaData.picture?.ifBlank { null }?.let { newLogo -> - if (newLogo != appLogo) appLogo = newLogo - } - metaData.name?.ifBlank { null }?.let { newName -> - if (newName != appName) appName = newName - } - } - } - } - - appLogo?.let { - Box( - remember { - Modifier - .size(Size35dp) - .clickable { nav.nav("Note/${baseApp.idHex}") } - }, - ) { - AsyncImage( - model = appLogo, - contentDescription = appName, - modifier = - remember { - Modifier - .size(Size35dp) - .clip(shape = CircleShape) - }, - ) - } - } -} - -@Composable -private fun DisplayBadges( - baseUser: User, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadAddressableNote( - aTag = BadgeProfilesEvent.createAddressTag(baseUser.pubkeyHex), - accountViewModel = accountViewModel, - ) { note -> - if (note != null) { - WatchAndRenderBadgeList( - note = note, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - nav = nav, - ) - } - } -} - -@Composable -private fun WatchAndRenderBadgeList( - note: AddressableNote, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - nav: INav, -) { - val badgeList by - note - .live() - .metadata - .map { (it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList() } - .distinctUntilChanged() - .observeAsState() - - badgeList?.let { list -> RenderBadgeList(list, loadProfilePicture, loadRobohash, nav) } -} - -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun RenderBadgeList( - list: ImmutableList, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - nav: INav, -) { - FlowRow( - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(vertical = 5.dp), - ) { - list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, loadProfilePicture, loadRobohash, nav) } - } -} - -@Composable -private fun LoadAndRenderBadge( - badgeAwardEvent: ETag, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - nav: INav, -) { - var baseNote by remember(badgeAwardEvent) { mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEvent)) } - - LaunchedEffect(key1 = badgeAwardEvent) { - if (baseNote == null) { - withContext(Dispatchers.IO) { - baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEvent) - } - } - } - - baseNote?.let { ObserveAndRenderBadge(it, loadProfilePicture, loadRobohash, nav) } -} - -@Composable -private fun ObserveAndRenderBadge( - it: Note, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - nav: INav, -) { - val badgeAwardState by it.live().metadata.observeAsState() - val baseBadgeDefinition by - remember(badgeAwardState) { derivedStateOf { badgeAwardState?.note?.replyTo?.firstOrNull() } } - - baseBadgeDefinition?.let { BadgeThumb(it, loadProfilePicture, loadRobohash, nav, Size35dp) } -} - -@Composable -fun BadgeThumb( - note: Note, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - nav: INav, - size: Dp, - pictureModifier: Modifier = Modifier, -) { - BadgeThumb(note, loadProfilePicture, loadRobohash, size, pictureModifier) { nav.nav("Note/${note.idHex}") } -} - -@Composable -fun BadgeThumb( - baseNote: Note, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - size: Dp, - pictureModifier: Modifier = Modifier, - onClick: ((String) -> Unit)? = null, -) { - Box( - remember { - Modifier - .width(size) - .height(size) - }, - ) { - WatchAndRenderBadgeImage(baseNote, loadProfilePicture, loadRobohash, size, pictureModifier, onClick) - } -} - -@Composable -private fun WatchAndRenderBadgeImage( - baseNote: Note, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - size: Dp, - pictureModifier: Modifier, - onClick: ((String) -> Unit)?, -) { - val noteState by baseNote.live().metadata.observeAsState() - val eventId = remember(noteState) { noteState?.note?.idHex } ?: return - val image by - remember(noteState) { - derivedStateOf { - val event = noteState?.note?.event as? BadgeDefinitionEvent - event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null } - } - } - - if (image == null) { - RobohashAsyncImage( - robot = "authornotfound", - contentDescription = stringRes(R.string.unknown_author), - modifier = - remember { - pictureModifier - .width(size) - .height(size) - }, - loadRobohash = loadRobohash, - ) - } else { - RobohashFallbackAsyncImage( - robot = eventId, - model = image!!, - contentDescription = stringRes(id = R.string.profile_image), - modifier = - remember { - pictureModifier - .width(size) - .height(size) - .clip(shape = CutCornerShape(20)) - .run { - if (onClick != null) { - this.clickable(onClick = { onClick(eventId) }) - } else { - this - } - } - }, - loadProfilePicture = loadProfilePicture, - loadRobohash = loadRobohash, - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun DrawBanner( - baseUser: User, - accountViewModel: AccountViewModel, -) { - val userState by baseUser.live().metadata.observeAsState() - val banner = remember(userState) { userState?.user?.info?.banner } - - val clipboardManager = LocalClipboardManager.current - var zoomImageDialogOpen by remember { mutableStateOf(false) } - - if (!banner.isNullOrBlank()) { - AsyncImage( - model = banner, - contentDescription = stringRes(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - placeholder = painterResource(R.drawable.profile_banner), - modifier = - Modifier - .fillMaxWidth() - .height(150.dp) - .combinedClickable( - onClick = { zoomImageDialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(banner)) }, - ), - ) - - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = RichTextParser.parseImageOrVideo(banner), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) - } - } else { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringRes(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .fillMaxWidth() - .height(150.dp), - ) - } -} - -@Composable -fun TabNotesNewThreads( - feedViewModel: NostrUserProfileNewThreadsFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column(Modifier.fillMaxHeight()) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun TabNotesConversations( - feedViewModel: NostrUserProfileConversationsFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column(Modifier.fillMaxHeight()) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun TabGallery( - feedViewModel: NostrUserProfileGalleryFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - LaunchedEffect(Unit) { feedViewModel.invalidateData() } - - Column(Modifier.fillMaxHeight()) { - SaveableGridFeedState(feedViewModel, scrollStateKey = ScrollStateKeys.PROFILE_GALLERY) { listState -> - RenderGalleryFeed( - feedViewModel, - listState, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} - -@Composable -fun TabFollowedTags( - baseUser: User, - account: AccountViewModel, - nav: INav, -) { - val items = - remember(baseUser) { - baseUser.latestContactList?.unverifiedFollowTagSet() - } - - Column( - Modifier - .fillMaxHeight() - .padding(vertical = 0.dp), - ) { - items?.let { - LazyColumn { - itemsIndexed(items) { index, hashtag -> - HashtagHeader( - tag = hashtag, - account = account, - onClick = { nav.nav("Hashtag/$hashtag") }, - ) - HorizontalDivider( - thickness = DividerThickness, - ) - } - } - } - } -} - -@Composable -fun TabBookmarks( - feedViewModel: NostrUserProfileBookmarksFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - LaunchedEffect(Unit) { feedViewModel.invalidateData() } - - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} - -@Composable -fun TabFollows( - baseUser: User, - feedViewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - WatchFollowChanges(baseUser, feedViewModel) - - Column(Modifier.fillMaxHeight()) { - RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) - } -} - -@Composable -fun TabFollowers( - baseUser: User, - feedViewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - WatchFollowerChanges(baseUser, feedViewModel) - - Column(Modifier.fillMaxHeight()) { - RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) - } -} - -@Composable -private fun WatchFollowChanges( - baseUser: User, - feedViewModel: UserFeedViewModel, -) { - val userState by baseUser.live().follows.observeAsState() - - LaunchedEffect(userState) { feedViewModel.invalidateData() } -} - -@Composable -private fun WatchFollowerChanges( - baseUser: User, - feedViewModel: UserFeedViewModel, -) { - val userState by baseUser.live().followers.observeAsState() - - LaunchedEffect(userState) { feedViewModel.invalidateData() } -} - -@Composable -fun TabReceivedZaps( - baseUser: User, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel) - - Column(Modifier.fillMaxHeight()) { - LnZapFeedView(zapFeedViewModel, accountViewModel, nav) - } -} - -@Composable -private fun WatchZapsAndUpdateFeed( - baseUser: User, - feedViewModel: NostrUserProfileZapsFeedViewModel, -) { - val userState by baseUser.live().zaps.observeAsState() - - LaunchedEffect(userState) { feedViewModel.invalidateData() } -} - -@Composable -fun TabReports( - baseUser: User, - feedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - WatchReportsAndUpdateFeed(baseUser, feedViewModel) - - Column(Modifier.fillMaxHeight()) { - Column { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} - -@Composable -private fun WatchReportsAndUpdateFeed( - baseUser: User, - feedViewModel: NostrUserProfileReportFeedViewModel, -) { - val userState by baseUser.live().reports.observeAsState() - LaunchedEffect(userState) { feedViewModel.invalidateData() } -} - -@Composable -fun TabRelays( - user: User, - accountViewModel: AccountViewModel, - nav: INav, -) { - val feedViewModel: RelayFeedViewModel = viewModel() - - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(user) { - feedViewModel.subscribeTo(user) - onDispose { feedViewModel.unsubscribeTo(user) } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Profile Relay Start") - feedViewModel.subscribeTo(user) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Profile Relay Stop") - feedViewModel.unsubscribeTo(user) - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - println("Profile Relay Dispose") - feedViewModel.unsubscribeTo(user) - } - } - - Column(Modifier.fillMaxHeight()) { - RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, nav = nav) - } -} - -@Composable -private fun MessageButton( - user: User, - accountViewModel: AccountViewModel, - nav: INav, -) { - val scope = rememberCoroutineScope() - - Button( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { - scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav.nav("Room/$it") } } - }, - contentPadding = ZeroPadding, - ) { - Icon( - painter = painterResource(R.drawable.ic_dm), - stringRes(R.string.send_a_direct_message), - modifier = Modifier.size(20.dp), - tint = Color.White, - ) - } -} - -@Composable -private fun EditButton(nav: INav) { - InnerEditButton { nav.nav(Route.EditProfile.route) } -} - -@Preview -@Composable -private fun InnerEditButtonPreview() { - InnerEditButton {} -} - -@Composable -private fun InnerEditButton(onClick: () -> Unit) { - Button( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = onClick, - contentPadding = ZeroPadding, - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.EditNote, - contentDescription = stringRes(R.string.edits_the_user_s_metadata), - ) - } -} - -@Composable -fun UnfollowButton( - shape: Shape = ButtonBorder, - onClick: () -> Unit, -) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = shape, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(R.string.unfollow), color = Color.White) - } -} - -@Composable -fun FollowButton( - text: Int = R.string.follow, - shape: Shape = ButtonBorder, - onClick: () -> Unit, -) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = onClick, - shape = shape, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(text), color = Color.White, textAlign = TextAlign.Center) - } -} - -@Composable -fun ShowUserButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(R.string.unblock), color = Color.White) - } -} - -@Composable -fun UserProfileDropDownMenu( - user: User, - popupExpanded: Boolean, - onDismiss: () -> Unit, - accountViewModel: AccountViewModel, -) { - DropdownMenu( - expanded = popupExpanded, - onDismissRequest = onDismiss, - ) { - val clipboardManager = LocalClipboardManager.current - - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_user_id)) }, - onClick = { - clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) - onDismiss() - }, - ) - - val actContext = LocalContext.current - - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForUser(user), - ) - putExtra( - Intent.EXTRA_TITLE, - stringRes(actContext, R.string.quick_action_share_browser_link), - ) - } - - val shareIntent = - Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) - ContextCompat.startActivity(actContext, shareIntent, null) - onDismiss() - }, - ) - - if (accountViewModel.userProfile() != user) { - HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.isHidden(user)) { - DropdownMenuItem( - text = { Text(stringRes(R.string.unblock_user)) }, - onClick = { - accountViewModel.show(user) - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(id = R.string.block_hide_user)) }, - onClick = { - accountViewModel.hide(user) - onDismiss() - }, - ) - } - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_spam_scam)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.SPAM) - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.report_hateful_speech)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.PROFANITY) - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_impersonation)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION) - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.report_nudity_porn)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.NUDITY) - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_illegal_behaviour)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL) - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_malware)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.MALWARE) - onDismiss() - }, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt new file mode 100644 index 0000000000..bbb4f51e9d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun BookmarkTabHeader(baseUser: User) { + val userState by baseUser.live().bookmarks.observeAsState() + + var userBookmarks by remember { mutableIntStateOf(0) } + + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newBookmarks = userState?.user?.latestBookmarkList?.countBookmarks() ?: 0 + + if (newBookmarks != userBookmarks) { + userBookmarks = newBookmarks + } + } + } + + Text(text = "$userBookmarks ${stringRes(R.string.bookmarks)}") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/NostrUserProfileBookmarksFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/NostrUserProfileBookmarksFeedViewModel.kt new file mode 100644 index 0000000000..e88e526638 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/NostrUserProfileBookmarksFeedViewModel.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class NostrUserProfileBookmarksFeedViewModel( + val user: User, + val account: Account, +) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileBookmarksFeedViewModel = + NostrUserProfileBookmarksFeedViewModel(user, account) + as NostrUserProfileBookmarksFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt new file mode 100644 index 0000000000..5a43e0eff4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabBookmarks( + feedViewModel: NostrUserProfileBookmarksFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + LaunchedEffect(Unit) { feedViewModel.invalidateData() } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/NostrUserProfileConversationsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/NostrUserProfileConversationsFeedViewModel.kt new file mode 100644 index 0000000000..0e27d9fefe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/NostrUserProfileConversationsFeedViewModel.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class NostrUserProfileConversationsFeedViewModel( + val user: User, + val account: Account, +) : FeedViewModel(UserProfileConversationsFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileConversationsFeedViewModel = + NostrUserProfileConversationsFeedViewModel(user, account) + as NostrUserProfileConversationsFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt new file mode 100644 index 0000000000..1594158400 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabNotesConversations( + feedViewModel: NostrUserProfileConversationsFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(Modifier.fillMaxHeight()) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt new file mode 100644 index 0000000000..0825762231 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun FollowersTabHeader(baseUser: User) { + val userState by baseUser.live().followers.observeAsState() + var followerCount by remember { mutableStateOf("--") } + + val text = stringRes(R.string.followers) + + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newFollower = (userState?.user?.transientFollowerCount()?.toString() ?: "--") + " " + text + + if (followerCount != newFollower) { + followerCount = newFollower + } + } + } + + Text(text = followerCount) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/NostrUserProfileFollowersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/NostrUserProfileFollowersUserFeedViewModel.kt new file mode 100644 index 0000000000..2660475ed8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/NostrUserProfileFollowersUserFeedViewModel.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel + +class NostrUserProfileFollowersUserFeedViewModel( + val user: User, + val account: Account, +) : UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileFollowersUserFeedViewModel = + NostrUserProfileFollowersUserFeedViewModel(user, account) + as NostrUserProfileFollowersUserFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt new file mode 100644 index 0000000000..2d94257893 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabFollowers( + baseUser: User, + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchFollowerChanges(baseUser, feedViewModel) + + Column(Modifier.fillMaxHeight()) { + RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) + } +} + +@Composable +private fun WatchFollowerChanges( + baseUser: User, + feedViewModel: UserFeedViewModel, +) { + val userState by baseUser.live().followers.observeAsState() + + LaunchedEffect(userState) { feedViewModel.invalidateData() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/FollowTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/FollowTabHeader.kt new file mode 100644 index 0000000000..c1c324ffac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/FollowTabHeader.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun FollowTabHeader(baseUser: User) { + val userState by baseUser.live().follows.observeAsState() + var followCount by remember { mutableStateOf("--") } + + val text = stringRes(R.string.follows) + + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newFollow = (userState?.user?.transientFollowCount()?.toString() ?: "--") + " " + text + + if (followCount != newFollow) { + followCount = newFollow + } + } + } + + Text(text = followCount) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/NostrUserProfileFollowsUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/NostrUserProfileFollowsUserFeedViewModel.kt new file mode 100644 index 0000000000..2776ad572c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/NostrUserProfileFollowsUserFeedViewModel.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel + +class NostrUserProfileFollowsUserFeedViewModel( + val user: User, + val account: Account, +) : UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileFollowsUserFeedViewModel = + NostrUserProfileFollowsUserFeedViewModel(user, account) + as NostrUserProfileFollowsUserFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt new file mode 100644 index 0000000000..77a059efe0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabFollows( + baseUser: User, + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchFollowChanges(baseUser, feedViewModel) + + Column(Modifier.fillMaxHeight()) { + RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) + } +} + +@Composable +private fun WatchFollowChanges( + baseUser: User, + feedViewModel: UserFeedViewModel, +) { + val userState by baseUser.live().follows.observeAsState() + + LaunchedEffect(userState) { feedViewModel.invalidateData() } +} 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 981fcebe5c..d815efebdc 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 @@ -46,6 +46,7 @@ fun GalleryCardCompose( modifier: Modifier = Modifier, accountViewModel: AccountViewModel, nav: INav, + ratio: Float = 1.0f, ) { WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, shortPreview = true) { CheckHiddenFeedWatchBlockAndReport( @@ -74,6 +75,7 @@ fun GalleryCardCompose( modifier = modifier, accountViewModel = accountViewModel, nav = nav, + ratio = ratio, ) } else { RedirectableGalleryCard( @@ -82,6 +84,7 @@ fun GalleryCardCompose( modifier = modifier, accountViewModel = accountViewModel, nav = nav, + ratio = ratio, ) } } @@ -92,6 +95,7 @@ fun GalleryCardCompose( modifier = modifier, accountViewModel = accountViewModel, nav = nav, + ratio = ratio, ) } } @@ -105,6 +109,7 @@ fun RedirectableGalleryCard( modifier: Modifier = Modifier, accountViewModel: AccountViewModel, nav: INav, + ratio: Float = 1.0f, ) { QuickActionGallery(baseNote = galleryNote, accountViewModel = accountViewModel) { showPopup -> ClickableNote( @@ -123,7 +128,7 @@ fun RedirectableGalleryCard( note = galleryNote, accountViewModel = accountViewModel, ) { - GalleryThumbnail(galleryNote, accountViewModel, nav) + GalleryThumbnail(galleryNote, accountViewModel, nav, ratio = ratio) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 2e01fc52e9..71d3b96512 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.components.GetMediaItem import com.vitorpamplona.amethyst.ui.components.GetVideoController import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.WatchAuthor @@ -77,6 +78,7 @@ fun GalleryThumbnail( baseNote: Note, accountViewModel: AccountViewModel, nav: INav, + ratio: Float = 1.0f, ) { val noteState by baseNote.live().metadata.observeAsState() val noteEvent = noteState?.note?.event ?: return @@ -134,7 +136,7 @@ fun GalleryThumbnail( emptyList() } - InnerRenderGalleryThumb(content, baseNote, accountViewModel) + InnerRenderGalleryThumb(content, baseNote, accountViewModel, ratio) } @Composable @@ -142,9 +144,10 @@ fun InnerRenderGalleryThumb( content: List, note: Note, accountViewModel: AccountViewModel, + ratio: Float = 1.0f, ) { if (content.isNotEmpty()) { - GalleryContentView(content, accountViewModel) + GalleryContentView(content, accountViewModel, ratio = ratio) } else { DisplayGalleryAuthorBanner(note) } @@ -162,16 +165,17 @@ fun DisplayGalleryAuthorBanner(note: Note) { fun GalleryContentView( contentList: List, accountViewModel: AccountViewModel, + ratio: Float = 1.0f, ) { AutoNonlazyGrid(contentList.size) { contentIndex -> when (val content = contentList[contentIndex]) { is MediaUrlImage -> SensitivityWarning(content.contentWarning != null, accountViewModel) { - UrlImageView(content, accountViewModel) + UrlImageView(content, accountViewModel, ratio = ratio) } is MediaUrlVideo -> SensitivityWarning(content.contentWarning != null, accountViewModel) { - UrlVideoView(content, accountViewModel) + UrlVideoView(content, accountViewModel, ratio = ratio) } } } @@ -182,8 +186,9 @@ fun UrlImageView( content: MediaUrlImage, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, + ratio: Float = 1.0f, ) { - val defaultModifier = Modifier.fillMaxSize().aspectRatio(1f) + val defaultModifier = Modifier.fillMaxSize().aspectRatio(ratio) val showImage = remember { @@ -212,7 +217,9 @@ fun UrlImageView( defaultModifier, ) } else { - DisplayUrlWithLoadingSymbol(content) + WaitAndDisplay { + DisplayUrlWithLoadingSymbol(content) + } } } is AsyncImagePainter.State.Error -> { @@ -250,8 +257,9 @@ fun UrlImageView( fun UrlVideoView( content: MediaUrlVideo, accountViewModel: AccountViewModel, + ratio: Float = 1.0f, ) { - val defaultModifier = Modifier.fillMaxSize().aspectRatio(1f) + val defaultModifier = Modifier.fillMaxSize().aspectRatio(ratio) val automaticallyStartPlayback = remember(content) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/NostrUserProfileGalleryFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/NostrUserProfileGalleryFeedViewModel.kt new file mode 100644 index 0000000000..e24d2b2f3f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/NostrUserProfileGalleryFeedViewModel.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileGalleryFeedFilter +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class NostrUserProfileGalleryFeedViewModel( + val user: User, + val account: Account, +) : FeedViewModel(UserProfileGalleryFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileGalleryFeedViewModel = + NostrUserProfileGalleryFeedViewModel(user, account) + as NostrUserProfileGalleryFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt index 9cf067dbad..4ab78a01a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError @@ -40,6 +41,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -87,6 +89,14 @@ private fun GalleryFeedLoaded( nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() + + sharedPreferencesViewModel.init() + + var ratio = 1.0f + if (sharedPreferencesViewModel.sharedPrefs.modernGalleryStyle.value) { + ratio = 0.8f + } LazyVerticalGrid( columns = GridCells.Fixed(3), @@ -100,11 +110,12 @@ private fun GalleryFeedLoaded( baseNote = item, modifier = Modifier - .aspectRatio(1f) + .aspectRatio(ratio) .fillMaxSize() .animateItem(), accountViewModel = accountViewModel, nav = nav, + ratio = ratio, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt new file mode 100644 index 0000000000..613c1360ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabGallery( + feedViewModel: NostrUserProfileGalleryFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + LaunchedEffect(Unit) { feedViewModel.invalidateData() } + + Column(Modifier.fillMaxHeight()) { + SaveableGridFeedState(feedViewModel, scrollStateKey = ScrollStateKeys.PROFILE_GALLERY) { listState -> + RenderGalleryFeed( + feedViewModel, + listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt new file mode 100644 index 0000000000..301a5262e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun FollowedTagsTabHeader(baseUser: User) { + val userState by baseUser.live().follows.observeAsState() + + val usertags by remember(baseUser) { + derivedStateOf { + userState?.user?.latestContactList?.countFollowTags() ?: 0 + } + } + + Text(text = "$usertags ${stringRes(R.string.followed_tags)}") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt new file mode 100644 index 0000000000..b2d8720ba6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagHeader +import com.vitorpamplona.amethyst.ui.theme.DividerThickness + +@Composable +fun TabFollowedTags( + baseUser: User, + account: AccountViewModel, + nav: INav, +) { + val items = + remember(baseUser) { + baseUser.latestContactList?.unverifiedFollowTagSet() + } + + Column( + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp), + ) { + items?.let { + LazyColumn { + itemsIndexed(items) { index, hashtag -> + HashtagHeader( + tag = hashtag, + account = account, + onClick = { nav.nav("Hashtag/$hashtag") }, + ) + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayFollowUnfollowButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayFollowUnfollowButton.kt new file mode 100644 index 0000000000..9fe7176d21 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayFollowUnfollowButton.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton + +@Composable +fun DisplayFollowUnfollowButton( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val isLoggedInFollowingUser by + accountViewModel.account + .userProfile() + .live() + .follows + .map { it.user.isFollowing(baseUser) } + .distinctUntilChanged() + .observeAsState(initial = accountViewModel.account.isFollowing(baseUser)) + + val isUserFollowingLoggedIn by + baseUser + .live() + .follows + .map { it.user.isFollowing(accountViewModel.account.userProfile()) } + .distinctUntilChanged() + .observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile())) + + if (isLoggedInFollowingUser) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollow(baseUser) + } + } + } else { + if (isUserFollowingLoggedIn) { + FollowButton(R.string.follow_back) { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.follow(baseUser) + } + } + } else { + FollowButton(R.string.follow) { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.follow(baseUser) + } + } + } + } +} 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 new file mode 100644 index 0000000000..f80a8a02ca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.InformationDialog +import com.vitorpamplona.amethyst.ui.components.ClickableText +import com.vitorpamplona.amethyst.ui.components.InvoiceRequestCard +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog +import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon +import com.vitorpamplona.amethyst.ui.note.payViaIntent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse + +@Composable +fun DisplayLNAddress( + lud16: String?, + userHex: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var zapExpanded by remember { mutableStateOf(false) } + + var showErrorMessageDialog by remember { mutableStateOf(null) } + + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = stringRes(id = R.string.error_dialog_zap_error), + textContent = showErrorMessageDialog ?: "", + onClickStartMessage = { + nav.nav { + routeToMessage(userHex, showErrorMessageDialog, accountViewModel = accountViewModel) + } + }, + onDismiss = { showErrorMessageDialog = null }, + ) + } + + var showInfoMessageDialog by remember { mutableStateOf(null) } + if (showInfoMessageDialog != null) { + InformationDialog( + title = stringRes(context, R.string.payment_successful), + textContent = showInfoMessageDialog ?: "", + ) { + showInfoMessageDialog = null + } + } + + if (!lud16.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange) + + ClickableText( + text = AnnotatedString(lud16), + onClick = { zapExpanded = !zapExpanded }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = + Modifier + .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) + .weight(1f), + ) + } + + if (zapExpanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 5.dp), + ) { + InvoiceRequestCard( + lud16, + userHex, + accountViewModel, + onSuccess = { + zapExpanded = false + // pay directly + if (accountViewModel.account.hasWalletConnectSetup()) { + accountViewModel.sendZapPaymentRequestFor(it, null, onSent = {}) { response -> + if (response is PayInvoiceSuccessResponse) { + showInfoMessageDialog = stringRes(context, R.string.payment_successful) + } else if (response is PayInvoiceErrorResponse) { + showErrorMessageDialog = + response.error?.message + ?: response.error?.code?.toString() + ?: stringRes(context, R.string.error_parsing_error_message) + } + } + } else { + payViaIntent(it, context, { zapExpanded = false }, { showErrorMessageDialog = it }) + } + }, + onClose = { zapExpanded = false }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt new file mode 100644 index 0000000000..6454778b57 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -0,0 +1,260 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ClickableText +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.DrawPlayName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.NostrUserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size25Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList +import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag +import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.TelegramIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims + +@Composable +fun DrawAdditionalInfo( + baseUser: User, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val userState by baseUser.live().metadata.observeAsState() + val user = remember(userState) { userState?.user } ?: return + val tags = userState?.user?.info?.tags + + val uri = LocalUriHandler.current + val clipboardManager = LocalClipboardManager.current + + user.toBestDisplayName().let { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) { + CreateTextWithEmoji( + text = it, + tags = tags, + fontWeight = FontWeight.Bold, + fontSize = 25.sp, + ) + Spacer(StdHorzSpacer) + user.info?.pronouns?.let { + Text( + text = "($it)", + ) + Spacer(StdHorzSpacer) + } + + DrawPlayName(it) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = user.pubkeyDisplayHex(), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), + color = MaterialTheme.colorScheme.placeholderText, + ) + + IconButton( + modifier = + Modifier + .size(25.dp) + .padding(start = 5.dp), + onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) }, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringRes(id = R.string.copy_npub_to_clipboard), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + + var dialogOpen by remember { mutableStateOf(false) } + + if (dialogOpen) { + ShowQRDialog( + user = user, + accountViewModel = accountViewModel, + onScan = { + dialogOpen = false + nav.nav(it) + }, + onClose = { dialogOpen = false }, + ) + } + + IconButton( + modifier = Size25Modifier, + onClick = { dialogOpen = true }, + ) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + contentDescription = stringRes(id = R.string.show_npub_as_a_qr_code), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + + DisplayBadges(baseUser, accountViewModel, nav) + + DisplayNip05ProfileStatus(user, accountViewModel) + + val website = user.info?.website + if (!website.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = MaterialTheme.colorScheme.placeholderText, + imageVector = Icons.Default.Link, + contentDescription = stringRes(R.string.website), + modifier = Modifier.size(16.dp), + ) + + ClickableText( + text = AnnotatedString(website.removePrefix("https://")), + onClick = { + website.let { + runCatching { + if (it.contains("://")) { + uri.openUri(it) + } else { + uri.openUri("http://$it") + } + } + } + }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), + ) + } + } + + val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } + val pubkeyHex = remember { baseUser.pubkeyHex } + DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) + + val identities = user.latestMetadata?.identityClaims() + if (!identities.isNullOrEmpty()) { + identities.forEach { identity: IdentityClaimTag -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = Color.Unspecified, + painter = painterResource(id = getIdentityClaimIcon(identity)), + contentDescription = stringRes(getIdentityClaimDescription(identity)), + modifier = Modifier.size(16.dp), + ) + + ClickableText( + text = AnnotatedString(identity.identity), + onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = + Modifier + .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) + .weight(1f), + ) + } + } + } + + user.info?.about?.let { + Row( + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), + ) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = it, + canPreview = false, + quotesLeft = 1, + tags = EmptyTagList, + backgroundColor = background, + id = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + DisplayAppRecommendations(appRecommendations, accountViewModel, nav) +} + +fun getIdentityClaimIcon(identity: IdentityClaimTag): Int = + when (identity) { + is TwitterIdentity -> R.drawable.x + is TelegramIdentity -> R.drawable.telegram + is MastodonIdentity -> R.drawable.mastodon + is GitHubIdentity -> R.drawable.github + else -> R.drawable.github + } + +fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = + when (identity) { + is TwitterIdentity -> R.string.twitter + is TelegramIdentity -> R.string.telegram + is MastodonIdentity -> R.string.mastodon + is GitHubIdentity -> R.string.github + else -> R.drawable.github + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt new file mode 100644 index 0000000000..ff883a05c9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DrawBanner( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val userState by baseUser.live().metadata.observeAsState() + val banner = remember(userState) { userState?.user?.info?.banner } + + val clipboardManager = LocalClipboardManager.current + var zoomImageDialogOpen by remember { mutableStateOf(false) } + + if (!banner.isNullOrBlank()) { + AsyncImage( + model = banner, + contentDescription = stringRes(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + placeholder = painterResource(R.drawable.profile_banner), + modifier = + Modifier + .fillMaxWidth() + .height(150.dp) + .combinedClickable( + onClick = { zoomImageDialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(banner)) }, + ), + ) + + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = RichTextParser.parseImageOrVideo(banner), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth() + .height(150.dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt new file mode 100644 index 0000000000..ab162e8306 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ZeroPadding + +@Composable +fun EditButton(nav: INav) { + InnerEditButton { nav.nav(Route.EditProfile.route) } +} + +@Preview +@Composable +fun InnerEditButtonPreview() { + InnerEditButton {} +} + +@Composable +fun InnerEditButton(onClick: () -> Unit) { + Button( + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), + onClick = onClick, + contentPadding = ZeroPadding, + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.EditNote, + contentDescription = stringRes(R.string.edits_the_user_s_metadata), + ) + } +} 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 new file mode 100644 index 0000000000..705575cdd9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.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( + user: User, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + + Button( + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), + onClick = { + scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav.nav("Room/$it") } } + }, + contentPadding = ZeroPadding, + ) { + Icon( + painter = painterResource(R.drawable.ic_dm), + stringRes(R.string.send_a_direct_message), + modifier = Size20Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt new file mode 100644 index 0000000000..51f0ea0201 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.WatchIsHiddenUser + +@Composable +fun ProfileActions( + baseUser: User, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isMe by + remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } + + if (isMe) { + EditButton(nav) + } + + WatchIsHiddenUser(baseUser, accountViewModel) { isHidden -> + if (isHidden) { + ShowUserButton { accountViewModel.showUser(baseUser.pubkeyHex) } + } else { + DisplayFollowUnfollowButton(baseUser, accountViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt new file mode 100644 index 0000000000..39ddcb2c56 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt @@ -0,0 +1,175 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.NostrUserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.Size100dp +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.ZeroPadding +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.userProfileBorderModifier + +@Composable +fun ProfileHeader( + baseUser: User, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + nav: INav, + accountViewModel: AccountViewModel, +) { + var popupExpanded by remember { mutableStateOf(false) } + var zoomImageDialogOpen by remember { mutableStateOf(false) } + + Box { + DrawBanner(baseUser, accountViewModel) + + Box( + modifier = + Modifier + .statusBarsPadding() + .padding(start = 10.dp, end = 10.dp, top = 10.dp) + .size(40.dp) + .align(Alignment.TopEnd), + ) { + Button( + modifier = + Modifier + .size(30.dp) + .align(Alignment.Center), + onClick = { popupExpanded = true }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.background, + ), + contentPadding = ZeroPadding, + ) { + Icon( + tint = MaterialTheme.colorScheme.placeholderText, + imageVector = Icons.Default.MoreVert, + contentDescription = stringRes(R.string.more_options), + ) + + UserProfileDropDownMenu( + baseUser, + popupExpanded, + { popupExpanded = false }, + accountViewModel, + ) + } + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp) + .padding(top = 100.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + val clipboardManager = LocalClipboardManager.current + + ClickableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = Size100dp, + modifier = MaterialTheme.colorScheme.userProfileBorderModifier, + onClick = { + if (baseUser.profilePicture() != null) { + zoomImageDialogOpen = true + } + }, + onLongClick = { + it.info?.picture?.let { it1 -> + clipboardManager.setText( + AnnotatedString(it1), + ) + } + }, + ) + + Spacer(Modifier.weight(1f)) + + Row( + modifier = + Modifier + .height(Size35dp) + .padding(bottom = 3.dp), + ) { + MessageButton(baseUser, accountViewModel, nav) + + ProfileActions(baseUser, accountViewModel, nav) + } + } + + DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) + + HorizontalDivider(modifier = Modifier.padding(top = 6.dp)) + } + } + + val profilePic = baseUser.profilePicture() + if (zoomImageDialogOpen && profilePic != null) { + ZoomableImageDialog( + RichTextParser.parseImageOrVideo(profilePic), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt new file mode 100644 index 0000000000..79c05f7ef9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -0,0 +1,152 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header + +import android.content.Intent +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.externalLinkForUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip56Reports.ReportEvent + +@Composable +fun UserProfileDropDownMenu( + user: User, + popupExpanded: Boolean, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + DropdownMenu( + expanded = popupExpanded, + onDismissRequest = onDismiss, + ) { + val clipboardManager = LocalClipboardManager.current + + DropdownMenuItem( + text = { Text(stringRes(R.string.copy_user_id)) }, + onClick = { + clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) + onDismiss() + }, + ) + + val actContext = LocalContext.current + + DropdownMenuItem( + text = { Text(stringRes(R.string.quick_action_share)) }, + onClick = { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForUser(user), + ) + putExtra( + Intent.EXTRA_TITLE, + stringRes(actContext, R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) + ContextCompat.startActivity(actContext, shareIntent, null) + onDismiss() + }, + ) + + if (accountViewModel.userProfile() != user) { + HorizontalDivider(thickness = DividerThickness) + if (accountViewModel.account.isHidden(user)) { + DropdownMenuItem( + text = { Text(stringRes(R.string.unblock_user)) }, + onClick = { + accountViewModel.show(user) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringRes(id = R.string.block_hide_user)) }, + onClick = { + accountViewModel.hide(user) + onDismiss() + }, + ) + } + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { Text(stringRes(id = R.string.report_spam_scam)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.SPAM) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.report_hateful_speech)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.PROFANITY) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(id = R.string.report_impersonation)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.report_nudity_porn)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.NUDITY) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(id = R.string.report_illegal_behaviour)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(id = R.string.report_malware)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.MALWARE) + onDismiss() + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt new file mode 100644 index 0000000000..14aa1f004d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun DisplayAppRecommendations( + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedState by appRecommendations.feedState.feedContent.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { appRecommendations.invalidateData() } + + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Loaded -> { + Column { + Text(stringRes(id = R.string.recommended_apps)) + + Recommends(state, nav) + } + } + else -> {} + } + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +fun Recommends( + loaded: FeedState.Loaded, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + FlowRow( + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(vertical = 5.dp), + ) { + items.list.forEach { app -> WatchApp(app, nav) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/NostrUserAppRecommendationsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/NostrUserAppRecommendationsFeedViewModel.kt new file mode 100644 index 0000000000..fdc0bf6c44 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/NostrUserAppRecommendationsFeedViewModel.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class NostrUserAppRecommendationsFeedViewModel( + val user: User, +) : FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { + class Factory( + val user: User, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserAppRecommendationsFeedViewModel = + NostrUserAppRecommendationsFeedViewModel(user) + as NostrUserAppRecommendationsFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt new file mode 100644 index 0000000000..3d935555e5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun WatchApp( + baseApp: Note, + nav: INav, +) { + val appState by baseApp.live().metadata.observeAsState() + + var appLogo by remember(baseApp) { mutableStateOf(null) } + var appName by remember(baseApp) { mutableStateOf(null) } + + LaunchedEffect(key1 = appState) { + withContext(Dispatchers.Default) { + (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.let { metaData -> + metaData.picture?.ifBlank { null }?.let { newLogo -> + if (newLogo != appLogo) appLogo = newLogo + } + metaData.name?.ifBlank { null }?.let { newName -> + if (newName != appName) appName = newName + } + } + } + } + + appLogo?.let { + Box( + remember { + Modifier + .size(Size35dp) + .clickable { nav.nav("Note/${baseApp.idHex}") } + }, + ) { + AsyncImage( + model = appLogo, + contentDescription = appName, + modifier = + remember { + Modifier + .size(Size35dp) + .clip(shape = CircleShape) + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt new file mode 100644 index 0000000000..1c116cf6e9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -0,0 +1,242 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun DisplayBadges( + baseUser: User, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote( + BadgeProfilesEvent.createAddress(baseUser.pubkeyHex), + accountViewModel, + ) { note -> + if (note != null) { + WatchAndRenderBadgeList( + note = note, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + nav = nav, + ) + } + } +} + +@Composable +private fun WatchAndRenderBadgeList( + note: AddressableNote, + loadProfilePicture: Boolean, + loadRobohash: Boolean, + nav: INav, +) { + val badgeList by + note + .live() + .metadata + .map { (it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList() } + .distinctUntilChanged() + .observeAsState() + + badgeList?.let { list -> RenderBadgeList(list, loadProfilePicture, loadRobohash, nav) } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun RenderBadgeList( + list: ImmutableList, + loadProfilePicture: Boolean, + loadRobohash: Boolean, + nav: INav, +) { + FlowRow( + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(vertical = 5.dp), + ) { + list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, loadProfilePicture, loadRobohash, nav) } + } +} + +@Composable +private fun LoadAndRenderBadge( + badgeAwardEvent: ETag, + loadProfilePicture: Boolean, + loadRobohash: Boolean, + nav: INav, +) { + var baseNote by remember(badgeAwardEvent) { mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEvent)) } + + LaunchedEffect(key1 = badgeAwardEvent) { + if (baseNote == null) { + withContext(Dispatchers.IO) { + baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEvent) + } + } + } + + baseNote?.let { ObserveAndRenderBadge(it, loadProfilePicture, loadRobohash, nav) } +} + +@Composable +private fun ObserveAndRenderBadge( + it: Note, + loadProfilePicture: Boolean, + loadRobohash: Boolean, + nav: INav, +) { + val badgeAwardState by it.live().metadata.observeAsState() + val baseBadgeDefinition by + remember(badgeAwardState) { derivedStateOf { badgeAwardState?.note?.replyTo?.firstOrNull() } } + + baseBadgeDefinition?.let { BadgeThumb(it, loadProfilePicture, loadRobohash, nav, Size35dp) } +} + +@Composable +fun BadgeThumb( + note: Note, + loadProfilePicture: Boolean, + loadRobohash: Boolean, + nav: INav, + size: Dp, + pictureModifier: Modifier = Modifier, +) { + BadgeThumb(note, loadProfilePicture, loadRobohash, size, pictureModifier) { nav.nav("Note/${note.idHex}") } +} + +@Composable +fun BadgeThumb( + baseNote: Note, + loadProfilePicture: Boolean, + loadRobohash: Boolean, + size: Dp, + pictureModifier: Modifier = Modifier, + onClick: ((String) -> Unit)? = null, +) { + Box( + remember { + Modifier + .width(size) + .height(size) + }, + ) { + WatchAndRenderBadgeImage(baseNote, loadProfilePicture, loadRobohash, size, pictureModifier, onClick) + } +} + +@Composable +private fun WatchAndRenderBadgeImage( + baseNote: Note, + loadProfilePicture: Boolean, + loadRobohash: Boolean, + size: Dp, + pictureModifier: Modifier, + onClick: ((String) -> Unit)?, +) { + val noteState by baseNote.live().metadata.observeAsState() + val eventId = remember(noteState) { noteState?.note?.idHex } ?: return + val image by + remember(noteState) { + derivedStateOf { + val event = noteState?.note?.event as? BadgeDefinitionEvent + event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null } + } + } + + if (image == null) { + RobohashAsyncImage( + robot = "authornotfound", + contentDescription = stringRes(R.string.unknown_author), + modifier = + remember { + pictureModifier + .width(size) + .height(size) + }, + loadRobohash = loadRobohash, + ) + } else { + RobohashFallbackAsyncImage( + robot = eventId, + model = image!!, + contentDescription = stringRes(id = R.string.profile_image), + modifier = + remember { + pictureModifier + .width(size) + .height(size) + .clip(shape = CutCornerShape(20)) + .run { + if (onClick != null) { + this.clickable(onClick = { onClick(eventId) }) + } else { + this + } + } + }, + loadProfilePicture = loadProfilePicture, + loadRobohash = loadRobohash, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/NostrUserProfileMutualFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/NostrUserProfileMutualFeedViewModel.kt new file mode 100644 index 0000000000..f234e96e77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/NostrUserProfileMutualFeedViewModel.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileMutualFeedFilter +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class NostrUserProfileMutualFeedViewModel( + val user: User, + val account: Account, +) : FeedViewModel(UserProfileMutualFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileMutualFeedViewModel = + NostrUserProfileMutualFeedViewModel(user, account) + as NostrUserProfileMutualFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt new file mode 100644 index 0000000000..c06a0d66dd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabMutualConversations( + feedViewModel: NostrUserProfileMutualFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(Modifier.fillMaxHeight()) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/NostrUserProfileNewThreadsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/NostrUserProfileNewThreadsFeedViewModel.kt new file mode 100644 index 0000000000..1032d0bef8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/NostrUserProfileNewThreadsFeedViewModel.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class NostrUserProfileNewThreadsFeedViewModel( + val user: User, + val account: Account, +) : FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileNewThreadsFeedViewModel = + NostrUserProfileNewThreadsFeedViewModel(user, account) + as NostrUserProfileNewThreadsFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt new file mode 100644 index 0000000000..afb8cbb4d7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabNotesNewThreads( + feedViewModel: NostrUserProfileNewThreadsFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(Modifier.fillMaxHeight()) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/RelayFeedView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index eedeb0a03e..d9eee03e14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/RelayFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index 0d6257a42f..a40162b8d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays import android.util.Log import androidx.compose.runtime.MutableState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt new file mode 100644 index 0000000000..5b000bc80d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun RelaysTabHeader(baseUser: User) { + val userState by baseUser.live().relays.observeAsState() + val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" } + + val userStateRelayInfo by baseUser.live().relayInfo.observeAsState() + val userRelays = + remember(userStateRelayInfo) { + userStateRelayInfo + ?.user + ?.latestContactList + ?.relays() + ?.size ?: "--" + } + + Text(text = "$userRelaysBeingUsed / $userRelays ${stringRes(R.string.relays)}") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt new file mode 100644 index 0000000000..6b2a036f67 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabRelays( + user: User, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: RelayFeedViewModel = viewModel() + + val lifeCycleOwner = LocalLifecycleOwner.current + + DisposableEffect(user) { + feedViewModel.subscribeTo(user) + onDispose { feedViewModel.unsubscribeTo(user) } + } + + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Profile Relay Start") + feedViewModel.subscribeTo(user) + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Profile Relay Stop") + feedViewModel.unsubscribeTo(user) + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + println("Profile Relay Dispose") + feedViewModel.unsubscribeTo(user) + } + } + + Column(Modifier.fillMaxHeight()) { + RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, nav = nav) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/NostrUserProfileReportFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/NostrUserProfileReportFeedViewModel.kt new file mode 100644 index 0000000000..4aee303be8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/NostrUserProfileReportFeedViewModel.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class NostrUserProfileReportFeedViewModel( + val user: User, +) : FeedViewModel(UserProfileReportsFeedFilter(user)) { + class Factory( + val user: User, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileReportFeedViewModel = NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt new file mode 100644 index 0000000000..8f99331482 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun ReportsTabHeader(baseUser: User) { + val userState by baseUser.live().reports.observeAsState() + var userReports by remember { mutableIntStateOf(0) } + + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newSize = UserProfileReportsFeedFilter(baseUser).feed().size + + if (newSize != userReports) { + userReports = newSize + } + } + } + + Text(text = "$userReports ${stringRes(R.string.reports)}") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt new file mode 100644 index 0000000000..784d86f45b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabReports( + baseUser: User, + feedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchReportsAndUpdateFeed(baseUser, feedViewModel) + + Column(Modifier.fillMaxHeight()) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt new file mode 100644 index 0000000000..78cd741726 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import com.vitorpamplona.amethyst.model.User + +@Composable +fun WatchReportsAndUpdateFeed( + baseUser: User, + feedViewModel: NostrUserProfileReportFeedViewModel, +) { + val userState by baseUser.live().reports.observeAsState() + LaunchedEffect(userState) { feedViewModel.invalidateData() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/LnZapFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/LnZapFeedState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt index e9b59483cd..b2a3f2cf3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/LnZapFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/LnZapFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/LnZapFeedView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt index c3e16f657e..440e421230 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/LnZapFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.padding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/LnZapFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/LnZapFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt index 9f6067c2b9..802e448638 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/LnZapFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt @@ -18,19 +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.ui.screen.loggedIn.profile +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps import android.util.Log import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.ammolite.relays.BundledUpdate import kotlinx.collections.immutable.ImmutableList @@ -42,16 +39,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class NostrUserProfileZapsFeedViewModel( - user: User, -) : LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) { - class Factory( - val user: User, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileZapsFeedViewModel = NostrUserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel - } -} - @Stable open class LnZapFeedViewModel( val dataSource: FeedFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/NostrUserProfileZapsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/NostrUserProfileZapsFeedViewModel.kt new file mode 100644 index 0000000000..f1d8a66ac3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/NostrUserProfileZapsFeedViewModel.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter + +class NostrUserProfileZapsFeedViewModel( + user: User, +) : LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) { + class Factory( + val user: User, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileZapsFeedViewModel = NostrUserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ShowUserButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ShowUserButton.kt new file mode 100644 index 0000000000..7def995e81 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ShowUserButton.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.ButtonPadding + +@Composable +fun ShowUserButton(onClick: () -> Unit) { + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.unblock), color = Color.White) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt new file mode 100644 index 0000000000..0355ca81ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TabReceivedZaps( + baseUser: User, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel) + + Column(Modifier.fillMaxHeight()) { + LnZapFeedView(zapFeedViewModel, accountViewModel, nav) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchIsHiddenUser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchIsHiddenUser.kt new file mode 100644 index 0000000000..c66cf97679 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchIsHiddenUser.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun WatchIsHiddenUser( + baseUser: User, + accountViewModel: AccountViewModel, + content: @Composable (Boolean) -> Unit, +) { + val isHidden by + accountViewModel.account.liveHiddenUsers + .map { + it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) + }.observeAsState(accountViewModel.account.isHidden(baseUser)) + + content(isHidden) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt new file mode 100644 index 0000000000..cd2fec5d04 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import com.vitorpamplona.amethyst.model.User + +@Composable +fun WatchZapsAndUpdateFeed( + baseUser: User, + feedViewModel: NostrUserProfileZapsFeedViewModel, +) { + val userState by baseUser.live().zaps.observeAsState() + + LaunchedEffect(userState) { feedViewModel.invalidateData() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapTabHeader.kt new file mode 100644 index 0000000000..1a8eb58ef3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapTabHeader.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.math.BigDecimal + +@Composable +fun ZapTabHeader(baseUser: User) { + val userState by baseUser.live().zaps.observeAsState() + var zapAmount by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = userState) { + launch(Dispatchers.Default) { + val tempAmount = baseUser.zappedAmount() + if (zapAmount != tempAmount) { + zapAmount = tempAmount + } + } + } + + Text(text = "${showAmountInteger(zapAmount)} ${stringRes(id = R.string.zaps)}") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt index 989050479d..7d360ad6f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt @@ -65,7 +65,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.quartz.nip01Core.UserMetadata +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata @Preview @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 2c2b9c88de..306ac1f372 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -75,7 +75,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForSearchAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.ChannelName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 45e4e4ceae..efcb0a3edb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -48,10 +48,12 @@ import androidx.core.os.LocaleListCompat import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType +import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow import com.vitorpamplona.amethyst.ui.navigation.INav @@ -187,6 +189,12 @@ fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) { TitleExplainer(stringRes(FeatureSetType.PERFORMANCE.resourceId)), ) + val galleryItems = + persistentListOf( + TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)), + TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)), + ) + val showImagesIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowImages.screenCode val videoIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyStartPlayback.screenCode val linkIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowUrlPreview.screenCode @@ -204,6 +212,8 @@ fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) { val featureSetIndex = sharedPreferencesViewModel.sharedPrefs.featureSet.screenCode + val galleryIndex = + sharedPreferencesViewModel.sharedPrefs.gallerySet.screenCode Column( Modifier @@ -295,9 +305,19 @@ fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) { ) { sharedPreferencesViewModel.updateFeatureSetType(parseFeatureSetType(it)) } + Spacer(modifier = HalfVertSpacer) Spacer(modifier = HalfVertSpacer) + SettingsRow( + R.string.gallery_style, + R.string.gallery_style_description, + galleryItems, + galleryIndex, + ) { + sharedPreferencesViewModel.updateGallerySetType(parseGalleryType(it)) + } + PushNotificationSettingsRow(sharedPreferencesViewModel) } } 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 d9cd9d07d2..c82b5255b9 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 @@ -52,7 +52,6 @@ import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -127,6 +126,7 @@ import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent @@ -147,8 +147,8 @@ import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.LevelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ThinSendButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -163,32 +163,33 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.bounties.getReward +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.forks.forkFromAddress import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent -import com.vitorpamplona.quartz.nip13Pow.pow +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackEvent -import com.vitorpamplona.quartz.nip34Git.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.GitRepositoryEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +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 @@ -196,22 +197,21 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -474,7 +474,7 @@ private fun FullBleedNoteCompose( DisplayLocation(geo, nav) } - val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } + val baseReward = remember { noteEvent.bountyBaseReward()?.let { Reward(it) } } if (baseReward != null) { DisplayReward(baseReward, baseNote, accountViewModel, nav) } @@ -564,6 +564,17 @@ private fun FullBleedNoteCompose( ) } else if (noteEvent is ChatMessageRelayListEvent) { DisplayDMRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is ChatMessageEncryptedFileHeaderEvent) { + RenderChatMessageEncryptedFile( + baseNote, + false, + canPreview, + 3, + backgroundColor, + editState, + accountViewModel, + nav, + ) } else if (noteEvent is AdvertisedRelayListEvent) { DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { @@ -845,7 +856,6 @@ private fun RenderClassifiedsReaderForThread( } var message by remember { mutableStateOf(TextFieldValue(msg)) } - val scope = rememberCoroutineScope() TextField( value = message, @@ -868,9 +878,13 @@ private fun RenderClassifiedsReaderForThread( isActive = message.text.isNotBlank(), modifier = EditFieldTrailingIconModifier, ) { - scope.launch(Dispatchers.IO) { - note.author?.let { - nav.nav(routeToMessage(it, note.toNostrUri() + "\n\n" + msg, accountViewModel)) + note.author?.let { + nav.nav { + routeToMessage( + it, + note.toNostrUri() + "\n\n" + msg, + accountViewModel = accountViewModel, + ) } } } @@ -1004,7 +1018,7 @@ private fun RenderWikiHeaderForThread( } forkedAddress?.let { - LoadAddressableNote(aTag = it, accountViewModel = accountViewModel) { originalVersion -> + LoadAddressableNote(it, accountViewModel) { originalVersion -> if (originalVersion != null) { ForkInformationRow(originalVersion, Modifier.fillMaxWidth(), accountViewModel, nav) } 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 c69ee81a10..e0414782a6 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 @@ -103,7 +103,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.VideoReactionColumnPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 1d24270202..e59d34fa99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -205,6 +205,8 @@ val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp) val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp) val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp) +val CashuCardBorders = Modifier.fillMaxWidth().padding(10.dp).clip(shape = QuoteBorder) + val EditFieldModifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp).fillMaxWidth() val EditFieldTrailingIconModifier = Modifier.height(26.dp).padding(start = 5.dp, end = 0.dp) @@ -268,7 +270,7 @@ val chatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape) val AuthorInfoVideoFeed = Modifier.width(75.dp).padding(end = 15.dp) val messageDetailsModifier = Modifier.height(Size25dp) -val messageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp) +val messageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 5.dp) val inlinePlaceholder = Placeholder( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index e1c66fecad..19e0126ae8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -58,8 +58,8 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.resolveDefaults -import com.patrykandpatrick.vico.compose.style.ChartStyle -import com.patrykandpatrick.vico.core.DefaultColors +import com.patrykandpatrick.vico.compose.common.VicoTheme +import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel @@ -119,6 +119,9 @@ private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.05f) private val DarkChatBackground = DarkColorPalette.onSurface.copy(alpha = 0.12f) private val LightChatBackground = LightColorPalette.onSurface.copy(alpha = 0.08f) +private val DarkChatDraftBackground = DarkColorPalette.onSurface.copy(alpha = 0.15f) +private val LightChatDraftBackground = LightColorPalette.onSurface.copy(alpha = 0.15f) + private val DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f) private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f) @@ -387,6 +390,9 @@ val ColorScheme.subtleBorder: Color val ColorScheme.chatBackground: Color get() = if (isLight) LightChatBackground else DarkChatBackground +val ColorScheme.chatDraftBackground: Color + get() = if (isLight) LightChatDraftBackground else DarkChatDraftBackground + val ColorScheme.subtleButton: Color get() = if (isLight) LightSubtleButton else DarkSubtleButton @@ -441,22 +447,34 @@ val ColorScheme.largeRelayIconModifier: Modifier val ColorScheme.selectedReactionBoxModifier: Modifier get() = if (isLight) LightSelectedReactionBoxModifier else DarkSelectedReactionBoxModifier -val ColorScheme.chartStyle: ChartStyle - get() { - val defaultColors = if (isLight) DefaultColors.Light else DefaultColors.Dark - return ChartStyle.fromColors( - axisLabelColor = Color(defaultColors.axisLabelColor), - axisGuidelineColor = Color(defaultColors.axisGuidelineColor), - axisLineColor = Color(defaultColors.axisLineColor), - entityColors = - listOf( - defaultColors.entity1Color, - defaultColors.entity2Color, - defaultColors.entity3Color, - ).map(::Color), - elevationOverlayColor = Color(defaultColors.elevationOverlayColor), - ) - } +val chartLightColors = + VicoTheme( + candlestickCartesianLayerColors = + CandlestickCartesianLayerColors( + Color(0xff0ac285), + Color(0xff000000), + Color(0xffe8304f), + ), + columnCartesianLayerColors = listOf(Color(0xff3287ff), Color(0xff0ac285), Color(0xffffab02)), + lineColor = Color(0xffbcbfc2), + textColor = Color(0xff000000), + ) + +val chartDarkColors = + VicoTheme( + candlestickCartesianLayerColors = + CandlestickCartesianLayerColors( + Color(0xff0ac285), + Color(0xffffffff), + Color(0xffe8304f), + ), + columnCartesianLayerColors = listOf(Color(0xff3287ff), Color(0xff0ac285), Color(0xffffab02)), + lineColor = Color(0xff494c50), + textColor = Color(0xffffffff), + ) + +val ColorScheme.chartStyle: VicoTheme + get() = if (isLight) chartLightColors else chartDarkColors @Composable fun AmethystTheme( diff --git a/amethyst/src/main/res/values-ar/strings.xml b/amethyst/src/main/res/values-ar/strings.xml index 3ed653b875..2dbcb8bce3 100644 --- a/amethyst/src/main/res/values-ar/strings.xml +++ b/amethyst/src/main/res/values-ar/strings.xml @@ -16,6 +16,7 @@ صورة المجموعة محتوى فاضح محتوى عشوائي + عدد الأحداث المزعجة من هذا المرحل التمثيل تصرف غير قانوني أخرى @@ -33,6 +34,10 @@ الإبلاغ عن التمثيل الإبلاغ عن المحتوى الصريح / الفاضح الإبلاغ عن سلوك غير قانوني + الإبلاغ عن برمجيات خبيثة + إبلاغ المشرفين + برمجية خبيثة + مشرف قم بتسجيل الدخول باستخدام مفتاح خاص لتتمكن من الرد قم بتسجيل الدخول باستخدام مفتاح خاص لتتمكن من تعزيز المشاركات تسجيل الدخول باستخدام مفتاح خاص لإبداء الإعجاب بالمنشورات @@ -42,13 +47,16 @@ قم بتسجيل الدخول بمفتاحك الخاص للتتمكن من إلغاء متابعة المستخدمين قم بتسجيل الدخول باستخدام مفتاحك السري لتتمكن من إخفاء الكلمة أو الجملة قم بتسجيل الدخول باستخدام مفتاحك السري لتتمكن من إظهار الكلمة أو الجملة + زاب مشاهدة العد تعزيز معزز - تم تعديلها + معدلة تعديل #%1$s - الأصلية + أصلية إقتباس + أشتقاق + اقتراح تعديل مبلغ جديد في Sats إضافة "الرد على" @@ -70,6 +78,13 @@ شكرا جزيلا! المبلغ ب الSats إرسال Sats + صانع الرموز التعبيرية السرية + إضافة رمز تعبيري مع رسالة مخفية للمنشور + ملاحظة سرية للمستلم + رسالتي المخفية + بادئة مرئية + 😎 + إضافة إلى المنشور "خطأ في تحليل المعاينة لـ %1$s : %2$s" "معاينة صورة البطاقة لـ %1$s" قناة جديدة @@ -86,13 +101,16 @@ فشل تحميل الصورة عنوان الخادم (Relay Adress) المنشورات + بايتات الأخطاء + اخطأ الاتصال في هذه الجلسة المنشورات الرئيسية الرسائل الخاصة الرسائل العامة الموجز العام البحث أضف Relay + الإسم إسم العرض إسم العرض الخاص بي اسمي المميز @@ -103,10 +121,16 @@ رابط الصورة الزمزية رابط الشعار رابط الموقع + الضمائر LN عنوان LN رابط (قديم) + حفظه في معرض الصور تم حفظ الصورة في المعرض + بدأ تنزيل الفيديو… + بدأ تنزيل الوسائط… فشل حفظ الصورة + تم حفظ الفيديو في الجهاز + فشل حفظ الفيديو تحميل الصورة جاري التحميل… لا يمتلك المستخدم عنوان (Lightning Address) لاستقبال sats @@ -122,6 +146,8 @@ المحادثات الملاحظات الردود + الخاص بك + الألبوم "المتابَعون" "التقارير" المزيد من الخيارات @@ -130,6 +156,8 @@ Lightning عنوان نسخ معرف Nsec (كلمة المرور الخاصة بك) إلى الحافظة للنسخ الاحتياطي نسخ المفتاح الخاص إلى الحافظة + إظهار رمز QR المفتاح الخاص + إظهار رمز QR المفتاح الخاص المشفر نسخ المفتاح العام إلى الحافظة للمشاركة انسخ المفتاح العام (NPub) إلى الحافظة إرسال رسالة مباشرة @@ -143,9 +171,11 @@ نظف شعار التطبيق nsec / npub / hex private key + كلمة المرور لفك تشفير المفتاح الخاص إظهار كلمة السر اخفاء كلمة السر مفتاح غير صحيح + مفتاح غير صالح: %1$s "اقبل شروط الاستخدام " شروط الاستخدام يجب قبول شروط الإستخدام @@ -164,6 +194,7 @@ جاري تحميل الحساب "خطأ في تحميل الردود:" المحاولة مرة اخرى + لا توجد إشعارات حتى الآن. لا توجد ملاحظات. تحديث انشئ @@ -183,6 +214,8 @@ مترجم من الى تظهر في %1$s اولا + دردشة عامة حول %1$s + مجتمع عام حول %1$s ترجمة إلى %1$s لا تترجم ابداً من %1$s عنوان نوستر @@ -242,6 +275,8 @@ حذف الغاء المتابعة متابعة + إزالته من المعرض + إزالة هذه الوسيطة من المعرض. طلب الحذف سيطلب Amethyst حذف ملاحظتك من الخوادم (relays) الذي أنت متصل به حاليا. لا يوجد ضمان لحذف ملاحظتك بشكل دائم من تلك الخوادم أو من الخوادم الأخرى التي ربما حفظت فيها. حظر @@ -253,9 +288,12 @@ رسائل مزعجة أو رسائل خداع سلوك بغيض انتحال الشخصية + محتوى دموي أو خادش للحياء تصرف غير قانوني + برمجية خبيثة حظر المستخدم سوف يخفي محتواه في التطبيق الخاص بك. ملاحظاتك لا تزال قابلة للعرض علناً، بما في ذلك للأشخاص الذين تقوم بحظرهم. يتم إدراج المستخدمين المحظورين على شاشة مرشحات الأمان (Security Filters screen). + الإبلاغ عن إساءة الإستخدام ستكون جميع التقارير المنشورة مرئية للجمهور. اختياريا تستطيع توفير سياق إضافي حول تقريرك… سياق إضافي @@ -265,6 +303,7 @@ حظر / تبليغ حظر المفضلة + المسودات المفضلة الخاصة المفضلة العامة إضافة إلى المفضلات الخاصة @@ -279,6 +318,7 @@ المفتاح السري بصيغة nsec أو hex نشر الاستطلاع الحقول المطلوبة: + مستلمين الزاب وصف الاستطلاع الأساسي… الخيار %s وصف خيار الاستطلاع @@ -286,10 +326,12 @@ الحد الأدنى الحد الاقصى توافق الآراء + (0–100)% إغلاق بعد أيام تعذر التصويت تم إغلاق الاستطلاع لأي تصويت جديد + عدد الزاب يسمح بصوت واحد فقط لكل مستخدم في هذا النوع من الاستطلاع "البحث عن الحدث %1$s" أضف رسالة عامة @@ -301,13 +343,19 @@ ماذا يعني هذا؟ هذا المحتوى هو نفسه منذ النشر لقد تغير هذا المحتوى. ربما لم يشاهد مؤلفه التغيير أو يوافق عليه بعد. + إضافة وسائط أضف صورة اضف فيديو أضف مستند أضف إلى الرسالة + إضافة شرح توضيحي + صديقي المميز + استخدام الرابط المباشر وصف المحتوى/المحتويات A blue boat in a white sandy beach at sunset (قارب أزرق في شاطئ رملي أبيض عند غروب الشمس) + نوع الزاب + نوع الزاب لكل الخيارات عام يستطيع الجميع رؤية المعاملة والرسالة خاص @@ -316,7 +364,9 @@ المستقبل والجمهور لا يعرفان من الذي قام بإرسال الدفع لا يوجد أثر في نوستر، فقط على شبكة الLightning خادم الملفات + اختر خادم لرفع الملف به user@ او Lnaddres + خوادم الوسائط المرحلات الخاصة بك (NIP-95) إعداد Tor/Orbot الاتصال من خلال إعدادات Orbot الخاص بك @@ -328,9 +378,47 @@ جميع المتابعات العالمي قائمة الحسابات المكتومة + رابط/مرحل بصلي + مرحلات المحادثات الخاصة + استخدام Tor لإرسال و أستقبال الرسائل الخاصة + مرحلات غير موثوقة + أستخدام Tor لمرحلات الأحداث الواردة و المرسلة + مرحلات موثوقة + أستخدام Tor للإتصال بالمرحلات + الصور الشخصية + أستخدام Tor عند تنزيل الصور الشخصية + معاينة الروابط + أستخدام Tor لطلب معاينة الروابط + الصور + أستخدام Tor عند تنزيل الصور + الفيديوهات + أستخدام Tor عند تنزيل الفديوهات + المعاملات المالية + التحقق من عنوان Nostr + أستخدام Tor عند التحقق من عناوين NIP-05 + رفع الوسائط + أستخدام Tor عند رفع الوسائط + مضمن + Orbot + إيقاف أساسي + الافتراضي + الكل ما عدا الوسائط + الخصوصية الكاملة + مخصص + أستخدام Tor عندما يكون مطلوباً من الخادم + إخفاء عنوان الـIP من المرحلات العشوائية + إخفاء عنوان الـIP من الكل ماعدا الصور و الفيديوهات + إخفاء عنوان الـIP من جميع الإتصالات + خصصه بنفسك + رقم المنفذ غير صالح + أستخدام Orbot + قطع إتصال Tor/Orbot الرسائل الخاصة التنبيه عند وصول رسالة خاصة + تم استلام الزاب + تنبيهك عند أستقبالك زاب + ساتوشي %1$s من طرف %1$s ل %1$s تنبيه: @@ -341,6 +429,7 @@ إنضمام اليوم تحذير من المحتوى + هذا المنشور يحتوي على محتوى حساس، قد يجده البعض مزعج إخفاء المحتوى الحساس دائماً إظهار المحتوى الحساس دائماً إظهار تحذيرات المحتوى دائمًا @@ -349,8 +438,12 @@ تنبيه عندما تكون منشورتك قد تم التبليغ عنها رمز رد فعل جديد لم يتم تحديد أي نوع من ردود الفعل. اضغط مطولاً للتغيير + إضافة هدف الساتوشي المراد جمعه. سوف يظهر العملاء هذا كشريط تقدم لتحفيز التبرعات + الساتوشي المراد جمعه القراءة من الخادم (Relay) الكتابة للخادم + عدد البايتات الذي تم إرساله إلى هذا المرحل + عدد البايتات الذي تم أستقباله من هذا المرحل حدث خطأ أثناء محاولة الحصول على معلومات الخادم من %1$s المالك الاصدار @@ -364,6 +457,7 @@ اللغات الوسوم سياسات النشر + الأخطاء والإشعارات من هذا المرحل طول الرسالة الإشتراكات الفلاتر @@ -374,11 +468,18 @@ الحد الأدنى لل PoW المصادقة الدفع + استـرداد + إرسال إلى محفظة زاب تسخ الtoken نسخ الtoken من المحفظة + مباشر + غير متصل + انتهى + مجدولة تسجيل الخروج سوف يؤدي الى جميع المعلومات المحلية الخاصة بك. تأكد من وجود نسخة احتياطية لمفاتحك السرية لتجنب فقدان حسابك. هل تريد الاستمرار؟ العلامات المُتابعة + مرحلات مجتمع االدردشات المنشورات الموافق عليها @@ -410,6 +511,7 @@ التحقق من عنوان نوستر تحديد/إلغاء تحديد الكل الافتراضي + اعادة الوضع الافتراضى اختر مرحل للإستمرار عرض الموقع كـ ميزة جديدة @@ -447,12 +549,41 @@ موافق فشل الوصول إلى %1$s: %2$s + فشل في تجميع رابط NIP-11 لـ %1$s: %2$s + فشل الوصول إلى %1$s: %2$s + فشل تحليل الرد من %1$s: %2$s + المرحل رفض الطلب %1$s: %2$s + فشل الوصول إلى %1$s: %2$s + فشل %1$s مع الرمز %2$s + نشط لـ: الصفحة الرئيسية + المحادثات الخاصة + الدردشات + العالمي البحث + تقسيم و إعادة توجيه الزاب + سوف يقسمون ويوجهون العملاء الداعمين الزاب للمستخدمين بدلاً منك + البحث و إضافة مستخدم + اسم المستخدم أو إسم العرض + النسبة المئوية + 25 + تقسيم الزاب مع + تحويل الزاب إلى + تم الدفع + المحفظة %1$s خطأ في فتح تطبيق الموقِّع (signer app) + لم يتم العثور على الموقّع، تأكد مما إذا لم يتم إلغاء تثبيت تثبيته + تم رفض تطبيق التوقيع + تأكد من أن تطبيق الموقِّع قد أذن بهذه المعاملة الكلمات المخفية إخفاء كلمة أو جملة جديدة + صورة الملف الشخصي + إظهار الصور الشخصية حدد خيارا + تعذر دفع الفاتورة + لا يمكن السحب + خطأ في تحليل رابط إتصال NIP-47. تحقق مما إذا كان صحيح مع موفر المحفظة الخاص بك: %1$s. الخطأ: %2$s + خطأ في تحليل رابط إتصال NIP-47. تحقق مما إذا كان صحيح مع موفر المحفظة الخاص بك: %1$s. %1$s sats أرسلت الى محفظتك (الرسوم: sats %2$s ) تعذر جلب الinvoice من خوادم المستلم diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index 731d0bb496..afaf5ef7fd 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -104,6 +104,7 @@ ওয়েবসাইটের URL বিজলি-ঠিকানা বিজলি URL (অপ্রচলিত) + গ্যালারিতে সংরক্ষণ করুন ছবিটি গ্যালারিতে সংরক্ষণ করা হয়েছে ছবিটি সফলভাবে সংরক্ষণ করা যায় নি ছবিটি আপলোড করুন diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index b2636dda1a..365194101d 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -106,6 +106,7 @@ Globální kanál Kanál vyhledávání Přidat přeposílání + Jméno Zobrazované jméno Moje zobrazované jméno Ostrich McAwesome @@ -141,6 +142,7 @@ Konverzace Poznámky Odpovědi + Vaše Galerie "Sleduje" "Zprávy" @@ -537,6 +539,8 @@ Hotovo Zjednodušené Výkonost + Klasický + Moderní Systém Světlý Tmavý @@ -551,6 +555,8 @@ Skrýt navigační panely při rolování Režim UI Zvolte styl příspěvku + Styl galerie profilu + Zvolte styl galerie Načíst obrázek Spamovací uživatelé Ztlumené. Klikněte pro odztlumení diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index f34afb0771..40972e0333 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -1,10 +1,10 @@ - Zeigen Sie auf den QR Code - QR Code anzeigen + Auf den QR-Code verweisen + QR-Code anzeigen Profilbild Dein Profilbild - QR Code scannen + QR-Code scannen Trotzdem anzeigen Dieser Beitrag wurde ausgeblendet, weil er Ihre ausgeblendeten Benutzer oder Wörter erwähnt Der Beitrag wurde als unangemessen gekennzeichnet von @@ -106,6 +106,7 @@ Globale Feed Such-Feed Relay hinzufügen + Name Anzeigename Mein Anzeigename Ostrich McAwesome @@ -143,6 +144,7 @@ erie gespeichert Unterhaltungen Notizen Antworten + Deine Galerie "Folgt" "Berichte" @@ -542,6 +544,8 @@ anz der Bedingungen ist erforderlich Fertig Vereinfacht Leistung + Klassisch + Modern System Hell Dunkel @@ -556,6 +560,8 @@ anz der Bedingungen ist erforderlich Navigationsleisten beim Scrollen ausblenden UI-Modus Wählen Sie den Beitragsstil + Profilgalerie-Stil + Galeriestil auswählen Bild laden Spammer Stummgeschaltet. Drücken, um Ton einzuschalten diff --git a/amethyst/src/main/res/values-fa/strings.xml b/amethyst/src/main/res/values-fa/strings.xml index 9b89173bc6..71a721d723 100644 --- a/amethyst/src/main/res/values-fa/strings.xml +++ b/amethyst/src/main/res/values-fa/strings.xml @@ -346,6 +346,7 @@ افزودن به پیام افزودن عنوان دوست عزیز من + استفاده از آدرس مستقیم اینترنتی توصیف محتوا قایقی آبی در ساحل شنی سفید هنگام غروب نوع زپ @@ -537,6 +538,8 @@ کامل ساده عملکرد + کلاسیک + مدرن سیستم روشن تاریک @@ -551,6 +554,8 @@ پنهان کردن نوار پیمایش هنگام مرور حالت رابط کاربر قیافه پست را انتخاب کن + سبک گالری نمایه + انتخاب سبک گالری بارگیری تصویر اسپمر بیصدا شده. برای لغو کلیک کنید @@ -904,6 +909,7 @@ خدمت در دسترس نیست - این اغلب هنگامی که یک سرور بیش از حد شلوغ است یا برای تعمیرات تعطیل است پیش می آید مهلت درگاه تمام شد - مهلت سرور در نقش یک درگاه یا پراکسی تمام شد، در انتظار پاسخ نسخه HTTP پشتیبانی نمی شود - سرور ورژن HTTP درخواست را پشتیبانی نمی کند + Variant Also Negotiates - سرور یک مشکل پیکربندی داخلی دارد فضای ذخیره ناکافی - سرور فضای کافی برای پردازش موفق درخواست ندارد حلقه دیده شد - سرور یک حلقه بینهایت در پردازش این درخواست شناسایی کرد احراز هویت الزامی - کلاینت برای دسترسی به شبکه می بایست احراز هویت شده باشد diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml index 3a2d6e7121..f5533727bf 100644 --- a/amethyst/src/main/res/values-fr/strings.xml +++ b/amethyst/src/main/res/values-fr/strings.xml @@ -141,6 +141,7 @@ Conversations Notes Réponses + Les vôtres Galerie "Suivis" "Signalements" @@ -539,6 +540,8 @@ Complet Simplifié Performance + Classique + Moderne Système Clair Sombre @@ -553,6 +556,8 @@ Masquer les barres de navigation lors du défilement Mode UI Choisir le style du message + Style de la galerie de profil + Choisissez le style de la galerie Charger l\'image Spammeurs Silencieux. Cliquer pour réactiver le son diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 19fb944a7f..a1077eb6e8 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -106,6 +106,7 @@ वैश्विक सूचनावली खोज सूचनावली पुनःप्रसारक जोडें + नाम प्रदर्शन नाम मेरा प्रदर्शन नाम उष्ट्रपक्षी मक्बढिया @@ -141,6 +142,7 @@ सम्वाद टीकाएँ प्रतिवचन + आपका चित्रालय "का अनुचरण" "सूचनाएँ" @@ -539,6 +541,8 @@ सम्पूर्ण सरलीकृत वेगवान + प्राचीन + आधुनिक यन्त्रव्यवस्था प्रकाशवान अन्धकारमय @@ -553,6 +557,8 @@ मार्गदर्शनपट्टियों को छिपाएँ पृष्ठघुमाव करने पर प्रयोगमाध्यम शैली पत्र प्रकाशन शैली का चयन करें + परिचय चित्रालय शैली + चित्रालय शैली चुनें चित्र प्राप्त करें कचरालेख प्रेषक मौन किया गया। अमौन करने के लिए टाँकें diff --git a/amethyst/src/main/res/values-hu/strings.xml b/amethyst/src/main/res/values-hu/strings.xml index 86612ee458..8d0d447a34 100644 --- a/amethyst/src/main/res/values-hu/strings.xml +++ b/amethyst/src/main/res/values-hu/strings.xml @@ -53,7 +53,7 @@ Zap Megtekintések száma Megtolás - Megtolva + megtolta szerkesztve #%1$s szerkesztése eredeti @@ -106,6 +106,7 @@ Globális hírfolyam Keresési hírfolyam Egy átjátszó hozzáadása + Név Megjelenítendő név Saját megjelenítendő név Strucc McNagyszerű @@ -141,6 +142,7 @@ Beszélgetések Bejegyzések Válaszok + Az Öné Galéria "Követett" "Bejelentés" @@ -539,6 +541,8 @@ Teljes Egyszerűsített Teljesítmény + Klasszikus + Modern Rendszer Világos Sötét @@ -553,6 +557,8 @@ Navigációs sáv elrejtése görgetéskor Felhasználói felület módja Bejegyzés stílusának kiválasztása + Profilgaléria stílusa + Válassza ki a galéria stílusát Kép betöltése Spamelők Némítva. Koppintson a némitás megszüntetéséhez diff --git a/amethyst/src/main/res/values-nl/strings.xml b/amethyst/src/main/res/values-nl/strings.xml index cb6b179a24..daa6f5f5d8 100644 --- a/amethyst/src/main/res/values-nl/strings.xml +++ b/amethyst/src/main/res/values-nl/strings.xml @@ -539,6 +539,8 @@ Voltooid Vereenvoudigd Prestaties + Klassiek + Modern Systeem Licht Donker @@ -553,6 +555,8 @@ Navigatie verbergen bij scrollen UI modus Kies de stijl van het bericht + Profielgalerij stijl + Kies de galerijstijl Afbeelding laden Spammers Geen geluid. Klik om voor geluid diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 7bbddd0b48..c812a5d4ad 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -7,7 +7,7 @@ Zeskanuj QR kod Pokaż mimo wszystko Ten post został ukryty, ponieważ dotyczy ukrytych użytkowników lub słów - Post został wyciszony lub zgłoszony przez + Wpis został ukryty lub zgłoszony przez Wydarzenie jest wczytywane lub nie można go znaleźć na liście transmiterów 👀 Zdjęcie kanału @@ -80,7 +80,7 @@ Wiadomość dla odbiorcy Dziękuję bardzo! Kwota w Satsach - Wyślij Satsy + Wyślij "Błąd analizowania podglądu dla %1$s : %2$s" "Podgląd obrazu karty dla %1$s" Nowy kanał @@ -106,6 +106,7 @@ Kanał Ogólny Przeszukaj kanał Dodaj Transmiter + Imię Nazwa użytkownika Mój nick G Braun @@ -128,7 +129,7 @@ Nie udało się zapisać filmu Dodaj zdjęcie Wgrywanie… - Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać saty + Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satsy "odpowiedz tutaj.. " Kopiuje ID wpisu do schowka w celu udostępnienia w Nostr Kopiuj ID kanału (wpisu) do schowka @@ -141,6 +142,7 @@ Konwersacje Wpisy Odpowiedzi + Twoje Galeria "Obserwowani" "Zgłoszenia" @@ -205,13 +207,13 @@ odebranych wiadomości Usuń Automatycznie - przetłumaczono z - do - Najpierw wyświetl język %1$s + przetłumaczono język + na + Najpierw pokaż język %1$s Temat czatu: %1$s Temat dyskusji %1$s Zawsze tłumacz na %1$s - Język %1$s pokaż nietłumaczony + Pokaż oryginalny %1$s Adres Nostr nigdy teraz @@ -452,7 +454,7 @@ Powiadamia Cię, gdy nadejdzie prywatna wiadomość Otrzymano Zapy Powiadamia Cię, gdy ktoś prześle ci zapy - %1$s Satów + %1$s Satsów Od %1$s dla %1$s Powiadom: @@ -467,13 +469,13 @@ Zawsze ukrywaj wrażliwe treści Zawsze pokazuj wrażliwą zawartość Zawsze pokazuj ostrzeżenia dotyczące zawartości - Zalecenia: + Polecane: Filtruj spam z nieznajomych Ostrzegaj, gdy posty zostały zgłoszone przez osoby które obserwujesz Nowy Symbol Odzewu Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić Zapraiser - Dodaje docelową liczbę satów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn + Dodaje docelową liczbę satsów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn Docelowa kwota w Satach Zapraiser przy: %1$s. %2$s satach do celu Odczytaj z Transmitera @@ -539,6 +541,8 @@ Kompletny Uproszczony Dynamiczny + Klasyczny + Nowoczesny Automatyczny Jasny Ciemny @@ -553,9 +557,11 @@ Ukrywa pasek przewijania Tryb interfejsu Wybierz styl wpisu + Styl galerii + Wybierz styl galerii Załaduj obraz Spamerzy - Wyciszone. Kliknij, aby wyłączyć wyciszenie + Wyciszone. Kliknij, aby włączyć dźwięk Dźwięk włączony. Kliknij, aby wyciszyć Wyszukiwanie lokalne i zdalne Adres Nostr został zweryfikowany @@ -632,7 +638,7 @@ Szukaj i dodaj użytkownika Nick lub Login Brakująca konfiguracja LN - Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać saty + Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satsy Procentowo 25 Podziel zapsy z @@ -660,7 +666,7 @@ Mint dostarczył następujący komunikat błędu: %1$s Tokeny Cashu zostały już wydane. Cashu odebrano - %1$s saty zostały wysłane do Twojego portfela. (opłata: %2$s satów) + %1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satsów) W systemie nie znaleziono kompatybilnego portfela Cashu Nie można pobrać faktury z serwerów odbiorcy Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s @@ -678,7 +684,7 @@ Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika - Nieprawidłowa kwota faktury (%1$s satów) od %2$s. Powinieno być %3$s + Nieprawidłowa kwota faktury (%1$s satsów) od %2$s. Powinieno być %3$s Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s Nie można utworzyć faktury. Wiadomość od %1$s: %2$s Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7c95090245..cc6a97c13c 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -106,6 +106,7 @@ Feed Global Fonte de pesquisa Adicionar um Relay + Nome Nome de Exibição Meu nome de exibição McAwesome Ostrich @@ -141,6 +142,7 @@ Conversas Notas Respostas + Suas Galeria "Seguindo" "Denúncias" @@ -366,7 +368,7 @@ Você não tem nenhum servidor NIP-96. Você pode usar a lista de Amethyst, ou adicionar um abaixo ↓ Você não tem nenhum servidor de Blossom configurado. Você pode usar a lista de Amethyst, ou adicionar um abaixo ↓ Servidores de Mídia Integrados - Lista padrão do Ametite. Você pode adicioná-los individualmente ou adicionar a lista. + Lista padrão do Amethyst. Você pode adicioná-los individualmente ou adicionar a lista. Usar Lista Padrão Adicionar servidor de mídia Apagar servidor de mídia @@ -537,6 +539,8 @@ Concluído Simplificado Desempenho + Clássico + Moderno Sistema Claro Escuro @@ -551,6 +555,8 @@ Ocultar as barras de navegação ao rolar Modo de interface Escolha o estilo da publicação + Estilo da Galeria de Perfil + Escolha o estilo da galeria Carregar imagem Spammers Silenciado. Clique para ativar o som diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index d2f133d1b8..0ee2e0d4a6 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -106,6 +106,7 @@ Globalt Flöde Sök i Flödet Lägg till Relä + Namn Visningsnamn Mitt visningsnamn Struts McAwesome @@ -141,6 +142,7 @@ Konversationer Anteckningar Svar + Dina Galleri "Följer" "Rapporter" @@ -536,6 +538,8 @@ Slutförd Förenklad Prestanda + Klassisk + Modern System Ljus Mörk @@ -550,6 +554,8 @@ Dölj navigeringsfält vid bläddring UI läge Välj stilen för inlägg + Profilgalleri stil + Välj galleriets stil Ladda bild Spammare Ljud avstängt. Klicka för att ta bort ljudlöst diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index ed8efe43aa..4622785ca8 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -347,6 +347,7 @@ 添加到消息 添加标题 我最爱的朋友 + 使用直接链接 内容描述 日落时分,白色沙滩上的蓝色小船 打闪类型 @@ -538,6 +539,8 @@ 完整版 简化版 极速版 + 经典 + 现代 系统 浅色 深色 @@ -552,6 +555,8 @@ 滚动时隐藏导航栏 界面模式 选择帖子样式 + 资料页图库样式 + 选择图库样式 加载图像 垃圾邮件 静音。点击取消静音 @@ -810,6 +815,7 @@ 通过创建专用于关键词和标签检索的中继列表能够改善搜索结果。 设置 1 ~ 3 个支持 NIP-50 的中继,用于关键词和标签检索。 示例:\n - nostr.wine\n - reiny.nostr.band\n - reliy.noswhere.com + DM 上传 中继设置 公共时间线中继 这类中继会保存您的所有内容。Amethyst 会将您的事件发送到这些中继,其他人则通过这些中继查找您的内容。可以设置 1 ~ 3 个中继。它们可以是个人专用中继、付费中继或者公共中继。 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 619b29dc12..fda50f136c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -86,6 +86,15 @@ Thank you so much! Amount in Sats Send Sats + + Secret Emoji Maker + Add an emoji with hidden message to post + Secret Note to Receiver + My hidden message + Visible Prefix + 😎 + Add to Post + "Error parsing preview for %1$s : %2$s" "Preview Card Image for %1$s" New Channel @@ -111,6 +120,7 @@ Global Feed Search Feed Add a Relay + Name Display Name My display name Ostrich McAwesome @@ -146,6 +156,7 @@ Conversations Notes Replies + Yours Gallery "Follows" "Reports" @@ -634,6 +645,9 @@ Simplified Performance + Classic + Modern + System Light Dark @@ -650,6 +664,9 @@ UI Mode Choose the post style + Profile Gallery Style + Choose the gallery style + Load Image Spammers diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index 84431be216..0335f81304 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt index ba03be0d28..18c6e19ead 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.zaps import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import io.mockk.every import io.mockk.mockk diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt index e683d8d8bc..65f4018ee9 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt @@ -25,7 +25,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import io.mockk.MockKAnnotations import io.mockk.every @@ -70,7 +71,7 @@ class NewPostViewModelTest { every { accountViewModel.account } returns mockk() val textNoteEvent = mockk(relaxed = true) - every { textNoteEvent.mentions() } returns listOf("user1", "user2") + every { textNoteEvent.mentions() } returns listOf(PTag("user1"), PTag("user2")) every { replyingTo.event } returns textNoteEvent every { accountViewModel.userProfile() } returns mockk(relaxed = true) @@ -110,7 +111,7 @@ class NewPostViewModelTest { every { accountViewModel.account } returns mockk() val textNoteEvent = mockk(relaxed = true) - every { textNoteEvent.mentions() } returns listOf("") + every { textNoteEvent.mentions() } returns emptyList() every { replyingTo.event } returns textNoteEvent every { accountViewModel.userProfile() } returns mockk(relaxed = true) diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt index fbd0a38008..03a4ab3bd8 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt @@ -40,6 +40,7 @@ object Constants { RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.mom"), read = true, write = true, feedTypes = activeTypesGlobalChats), RelaySetupInfo(RelayUrlFormatter.normalize("wss://nos.lol"), read = true, write = true, feedTypes = activeTypesGlobalChats), // Paid relays + RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostrelites.org"), read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesGlobalChats), // Supporting NIP-50 RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.band"), read = true, write = false, feedTypes = activeTypesSearch), diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt index a0f681e057..70b61d5ccc 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.ammolite.relays import android.util.Log import com.vitorpamplona.ammolite.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relays.RelayState -import com.vitorpamplona.quartz.nip01Core.relays.sockets.WebsocketBuilderFactory +import com.vitorpamplona.quartz.nip01Core.relay.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt index ab513bc8ec..f585f438f3 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.ammolite.relays import android.util.Log import com.vitorpamplona.ammolite.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relays.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.RelayState import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt index 64007a254b..2a6b27ad2c 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.ammolite.relays import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relays.RelayState -import com.vitorpamplona.quartz.nip01Core.relays.SimpleClientRelay -import com.vitorpamplona.quartz.nip01Core.relays.SubscriptionCollection -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relays.sockets.WebsocketBuilderFactory +import com.vitorpamplona.quartz.nip01Core.relay.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.SimpleClientRelay +import com.vitorpamplona.quartz.nip01Core.relay.SubscriptionCollection +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent enum class FeedType { @@ -62,11 +62,11 @@ class RelaySubFilter( override fun getFilters(subscriptionId: String) = filter(subs.getSubscriptionFilters(subscriptionId)) - override fun allSubscriptions(): List = + override fun allSubscriptions(): List = subs.allSubscriptions().mapNotNull { filter -> val filters = filter(filter.value) if (filters.isNotEmpty()) { - com.vitorpamplona.quartz.nip01Core.relays + com.vitorpamplona.quartz.nip01Core.relay .Subscription(filter.key, filters) } else { null diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt index b228b74f58..303940f938 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.ammolite.relays import androidx.compose.runtime.Immutable import com.vitorpamplona.ammolite.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relays.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.RelayState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.BufferOverflow diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt index bd2f728db7..9c91ea374d 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.ammolite.relays -import com.vitorpamplona.quartz.nip01Core.relays.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.RelayStat object RelayStats { private val innerCache = mutableMapOf() diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt index 815d6bf249..f246b02195 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.ammolite.relays.filters import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter interface IPerRelayFilter { fun toRelay(forRelay: String): Filter diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt index 99d0fd4f8b..4a34dda591 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt @@ -21,12 +21,12 @@ package com.vitorpamplona.ammolite.relays.filters import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relays.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relays.filters.FilterSerializer +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher +import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer /** * This is a nostr filter with per-relay authors list and since parameters diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt index 486e61b82c..a1ba84ae32 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.ammolite.relays.filters import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relays.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relays.filters.FilterSerializer +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher +import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer /** * This is a nostr filter with per-relay authors list and since parameters diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt index 1a2b38456e..b46402730b 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt @@ -48,7 +48,7 @@ class RichTextParserBenchmark { assertNull( RichTextParser().createMediaContent( "https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-googleplay-universal-v0.83.10.apk", - EmptyTagList, + emptyMap(), null, ), ) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BloomFilterMurMur3Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BloomFilterMurMur3Benchmark.kt new file mode 100644 index 0000000000..c4579f013e --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BloomFilterMurMur3Benchmark.kt @@ -0,0 +1,135 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.hints.bloom.BloomFilterMurMur3 +import com.vitorpamplona.quartz.utils.RandomInstance +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BloomFilterMurMur3Benchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + val testEncoded = "100:10:AKiEIEQKALgRACEABA==:3" + + val key1 = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b".hexToByteArray() + val key2 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c".hexToByteArray() + val key3 = "560c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c".hexToByteArray() + + val keys = + mutableListOf().apply { + for (seed in 0..1000000) { + add(RandomInstance.bytes(32)) + } + } + + val keys2 = + mutableListOf().apply { + for (seed in 0..1000000) { + add(RandomInstance.bytes(32)) + } + } + + @Test + fun addExisting() { + val filter = BloomFilterMurMur3.decode(testEncoded) + benchmarkRule.measureRepeated { + filter.add(key1) + } + } + + @Test + fun addNew() { + val filter = BloomFilterMurMur3.decode(testEncoded) + benchmarkRule.measureRepeated { + filter.add(key3) + } + } + + @Test + fun mightContainTrue() { + val filter = BloomFilterMurMur3(10_000_000, 5) + filter.add(key1) + keys.forEach(filter::add) + benchmarkRule.measureRepeated { + filter.mightContain(key1) + } + } + + @Test + fun mightContainFalse() { + val filter = BloomFilterMurMur3(10_000_000, 5) + keys.forEach(filter::add) + benchmarkRule.measureRepeated { + filter.mightContain(key3) + } + } + + @Test + fun decode() { + benchmarkRule.measureRepeated { + BloomFilterMurMur3.decode(testEncoded) + } + } + + @Test + fun encode() { + val filter = BloomFilterMurMur3.decode(testEncoded) + benchmarkRule.measureRepeated { + filter.encode() + } + } + + @Test + fun largeFilterBuild() { + val bloomFilter = BloomFilterMurMur3(10_000_000, 5) + + benchmarkRule.measureRepeated { + keys.forEach(bloomFilter::add) + } + } + + @Test + fun largeFilterCheckExisting() { + val bloomFilter = BloomFilterMurMur3(10_000_000, 5) + keys.forEach(bloomFilter::add) + + benchmarkRule.measureRepeated { + keys.forEach(bloomFilter::mightContain) + } + } + + @Test + fun largeFilterCheckNew() { + val bloomFilter = BloomFilterMurMur3(10_000_000, 5) + keys.forEach(bloomFilter::add) + + benchmarkRule.measureRepeated { + keys2.forEach(bloomFilter::mightContain) + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt index 447e7824d1..a20ce449e1 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt @@ -26,8 +26,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.commons.data.LargeCache -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import org.junit.Assert.assertTrue import org.junit.Rule diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EnsureTest.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EnsureTest.kt new file mode 100644 index 0000000000..00ca68592a --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EnsureTest.kt @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.utils.ensure +import junit.framework.TestCase.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EnsureTest { + @get:Rule + val scope = BenchmarkRule() + + private val testArgs = arrayOf("0", "1f") + + companion object { + const val TAG = "0" + const val KEY_SIZE = 2 + + private val isHexChar = + BooleanArray(256).apply { + "0123456789abcdefABCDEF".forEach { this[it.code] = true } + } + + @JvmStatic + fun String.isHex() = all { isHexChar[it.code] } + } + + fun directCheckElse(args: Array) = + if (args[0] != TAG || args[1].length != KEY_SIZE || !args[1].isHex()) { + null + } else { + args[0] + args[1] + } + + fun directCheck(args: Array): String? { + if (args[0] != TAG || args[1].length != KEY_SIZE || !args[1].isHex()) return null + return args[0] + args[1] + } + + fun directCheck2(args: Array): String? { + if (args[0] == TAG && args[1].length == KEY_SIZE && args[1].isHex()) return args[0] + args[1] + return null + } + + fun linedCheck(args: Array): String? { + if (args[0] != TAG) return null + if (args[1].length != KEY_SIZE) return null + if (!args[1].isHex()) return null + return args[0] + args[1] + } + + fun ensureCheck(args: Array): String? { + ensure(args[0] == TAG) { return null } + ensure(args[1].length == KEY_SIZE) { return null } + ensure(args[1].isHex()) { return null } + return args[0] + args[1] + } + + fun checkCatch(args: Array): String? = + runCatching { + check(args[0] == TAG) + check(args[1].length == KEY_SIZE) + check(args[1].isHex()) + args[0] + args[1] + }.getOrNull() + + fun whenCheck(args: Array) = + when (false) { + (args[0] == TAG), + (args[1].length == KEY_SIZE), + (args[1].isHex()), + -> null + else -> args[0] + args[1] + } + + fun ensureAll(args: Array): String? { + ensureAll { + ensure { args[0] == TAG } + ensure { args[1].length == KEY_SIZE } + ensure { args[1].isHex() } + }.ifFail { return null } + return args[0] + args[1] + } + + @Test + fun directCheckElse() = scope.measureRepeated { assertTrue(directCheckElse(testArgs) != null) } + + @Test + fun directCheck() = scope.measureRepeated { assertTrue(directCheck(testArgs) != null) } + + @Test + fun linedCheck() = scope.measureRepeated { assertTrue(linedCheck(testArgs) != null) } + + @Test + fun ensureCheck() = scope.measureRepeated { assertTrue(ensureCheck(testArgs) != null) } + + @Test + fun checkCatch() = scope.measureRepeated { assertTrue(checkCatch(testArgs) != null) } + + @Test + fun whenCheck() = scope.measureRepeated { assertTrue(whenCheck(testArgs) != null) } + + @Test + fun ensureAll() = scope.measureRepeated { assertTrue(ensureAll(testArgs) != null) } +} + +class EnsureScope( + var passing: Boolean = true, +) { + inline fun ensure(predicate: () -> Boolean) { + if (passing) passing = predicate() + } + + inline fun ifFail(ifFail: () -> Unit) { + if (!passing) ifFail() + } +} + +inline fun ensureAll(block: EnsureScope.() -> Unit) = EnsureScope().apply(block) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt index 495bae1029..160c87ae9e 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt @@ -24,8 +24,10 @@ import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.EventFactory -import com.vitorpamplona.quartz.nip01Core.hasValidSignature import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.verify +import com.vitorpamplona.quartz.nip01Core.verifyId +import com.vitorpamplona.quartz.nip01Core.verifySignature import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.assertTrue import org.junit.Rule @@ -42,6 +44,15 @@ import org.junit.runner.RunWith class EventBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() + @Test + fun parseComplete() { + benchmarkRule.measureRepeated { + val tree = EventMapper.mapper.readTree(reqResponseEvent) + val event = EventMapper.fromJson(tree[2]) + assertTrue(event.verify()) + } + } + @Test fun parseREQString() { benchmarkRule.measureRepeated { EventMapper.mapper.readTree(reqResponseEvent) } @@ -54,22 +65,41 @@ class EventBenchmark { benchmarkRule.measureRepeated { EventMapper.fromJson(msg[2]) } } + @Test + fun checkId() { + val msg = EventMapper.mapper.readTree(reqResponseEvent) + val event = EventMapper.fromJson(msg[2]) + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.verifyId()) + } + } + @Test fun checkSignature() { val msg = EventMapper.mapper.readTree(reqResponseEvent) val event = EventMapper.fromJson(msg[2]) benchmarkRule.measureRepeated { // Should pass - assertTrue(event.hasValidSignature()) + assertTrue(event.verifySignature()) } } @Test - fun eventFactoryPerformanceTest() { + fun eventFactoryKind1PerformanceTest() { val now = TimeUtils.now() val tags = arrayOf(arrayOf("")) benchmarkRule.measureRepeated { EventFactory.create("id", "pubkey", now, 1, tags, "content", "sig") } } + + @Test + fun eventFactoryKind30818PerformanceTest() { + val now = TimeUtils.now() + val tags = arrayOf(arrayOf("")) + benchmarkRule.measureRepeated { + EventFactory.create("id", "pubkey", now, 30818, tags, "content", "sig") + } + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt index 9911848b4e..f50b6db627 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt @@ -25,8 +25,8 @@ import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.generateId -import com.vitorpamplona.quartz.nip01Core.hasCorrectIDHash import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.verifyId import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue import org.junit.Rule @@ -49,7 +49,7 @@ class EventCmdHasherBenchmark { benchmarkRule.measureRepeated { // Should pass - assertTrue(event.hasCorrectIDHash()) + assertTrue(event.verifyId()) } } @@ -58,7 +58,7 @@ class EventCmdHasherBenchmark { val event = Event.fromJson(largeKind1Event) benchmarkRule.measureRepeated { // Should pass - assertTrue(event.hasCorrectIDHash()) + assertTrue(event.verifyId()) } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt index c368eb3908..db54fc9c33 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt @@ -25,11 +25,10 @@ import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.experimental.Nip01Serializer +import com.vitorpamplona.quartz.utils.sha256.Sha256Hasher import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.security.MessageDigest /** * Benchmark, which will execute on an Android device. @@ -50,16 +49,6 @@ class EventCmdSerializerBenchmark { } } - @Test - fun eventSerializerManualTest() { - val event = Event.fromJson(largeKind1Event) - - benchmarkRule.measureRepeated { - val mapper = Nip01Serializer.StringWriter() - Nip01Serializer().serializeEventInto(event, mapper) - } - } - val specialEncoders = "Test\b\bTest\n\nTest\t\tTest\u000c\u000cTest\r\rTest\\Test\\\\Test\"Test/Test//Test" @@ -71,28 +60,12 @@ class EventCmdSerializerBenchmark { } } - @Test - fun jsonStringEncoderOurs() { - val serializer = Nip01Serializer() - benchmarkRule.measureRepeated { - serializer.escapeStringInto(specialEncoders, Nip01Serializer.StringWriter()) - } - } - @Test fun jsonStringEncoderSha256Jackson() { val jsonMapper = jacksonObjectMapper() benchmarkRule.measureRepeated { - val digest = MessageDigest.getInstance("SHA-256") - digest.update(jsonMapper.writeValueAsString(specialEncoders).toByteArray()) - } - } - - @Test - fun jsonStringEncoderSha256Ours() { - val serializer = Nip01Serializer() - benchmarkRule.measureRepeated { - serializer.escapeStringInto(specialEncoders, Nip01Serializer.BufferedDigestWriter(MessageDigest.getInstance("SHA-256"))) + val digest = Sha256Hasher() + digest.hash(jsonMapper.writeValueAsString(specialEncoders).toByteArray()) } } } 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 f842bf2d08..33493df011 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt @@ -23,14 +23,15 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.checkSignature import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +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 org.junit.Assert import org.junit.Assert.assertTrue @@ -60,9 +61,11 @@ class GiftWrapBenchmark { var events: NIP17Factory.Result? = null val countDownLatch = CountDownLatch(1) - NIP17Factory().createMsgNIP17( - message, - listOf(receiver.pubKey), + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + listOf(PTag(receiver.pubKey)), + ), sender, ) { events = it @@ -110,9 +113,11 @@ class GiftWrapBenchmark { var giftWrap: GiftWrapEvent? = null val countDownLatch = CountDownLatch(1) - NIP17Factory().createMsgNIP17( - message, - listOf(receiver.pubKey), + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + listOf(PTag(receiver.pubKey)), + ), sender, ) { giftWrap = it.wraps.first() @@ -126,7 +131,6 @@ class GiftWrapBenchmark { // Simulate Receiver benchmarkRule.measureRepeated { - CryptoUtils.clearCache() val counter = CountDownLatch(1) val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt index 42bd2aed6f..3bf8960596 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt @@ -23,18 +23,22 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hasCorrectIDHash -import com.vitorpamplona.quartz.nip01Core.hasVerifiedSignature -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEvent -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.Rumor -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.verifyId +import com.vitorpamplona.quartz.nip01Core.verifySignature +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue import org.junit.Rule @@ -60,18 +64,18 @@ class GiftWrapReceivingBenchmark { val countDownLatch = CountDownLatch(1) var wrap: GiftWrapEvent? = null - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - isDraft = true, - signer = sender, + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, ) { SealedRumorEvent.create( event = it, @@ -100,18 +104,18 @@ class GiftWrapReceivingBenchmark { val countDownLatch = CountDownLatch(1) var seal: SealedRumorEvent? = null - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - isDraft = true, - signer = sender, + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, ) { SealedRumorEvent.create( event = it, @@ -145,7 +149,7 @@ class GiftWrapReceivingBenchmark { val wrap = createWrap(sender, receiver) - benchmarkRule.measureRepeated { wrap.hasCorrectIDHash() } + benchmarkRule.measureRepeated { wrap.verifyId() } } @Test @@ -155,7 +159,7 @@ class GiftWrapReceivingBenchmark { val wrap = createWrap(sender, receiver) - benchmarkRule.measureRepeated { wrap.hasVerifiedSignature() } + benchmarkRule.measureRepeated { wrap.verifySignature() } } @Test @@ -167,7 +171,7 @@ class GiftWrapReceivingBenchmark { benchmarkRule.measureRepeated { assertNotNull( - CryptoUtils.decryptNIP44( + Nip44.decrypt( wrap.content, receiver.keyPair.privKey!!, wrap.pubKey.hexToByteArray(), @@ -184,7 +188,7 @@ class GiftWrapReceivingBenchmark { val wrap = createWrap(sender, receiver) val innerJson = - CryptoUtils.decryptNIP44( + Nip44.decrypt( wrap.content, receiver.keyPair.privKey!!, wrap.pubKey.hexToByteArray(), @@ -202,7 +206,7 @@ class GiftWrapReceivingBenchmark { benchmarkRule.measureRepeated { assertNotNull( - CryptoUtils.decryptNIP44( + Nip44.decrypt( seal.content, receiver.keyPair.privKey!!, seal.pubKey.hexToByteArray(), @@ -219,7 +223,7 @@ class GiftWrapReceivingBenchmark { val seal = createSeal(sender, receiver) val innerJson = - CryptoUtils.decryptNIP44( + Nip44.decrypt( seal.content, receiver.keyPair.privKey!!, seal.pubKey.hexToByteArray(), diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt index f7cbb9306e..5c1183c4e1 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt @@ -23,11 +23,15 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEvent -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import junit.framework.TestCase.assertTrue import org.junit.Rule import org.junit.Test @@ -53,18 +57,18 @@ class GiftWrapSigningBenchmark { benchmarkRule.measureRepeated { val countDownLatch = CountDownLatch(1) - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - isDraft = false, - signer = sender, + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, ) { countDownLatch.countDown() } @@ -82,18 +86,18 @@ class GiftWrapSigningBenchmark { var msg: ChatMessageEvent? = null - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - isDraft = false, - signer = sender, + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, ) { msg = it countDownLatch.countDown() @@ -124,18 +128,18 @@ class GiftWrapSigningBenchmark { var seal: SealedRumorEvent? = null - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - isDraft = false, - signer = sender, + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, ) { SealedRumorEvent.create( event = it, @@ -170,18 +174,18 @@ class GiftWrapSigningBenchmark { var wrap: GiftWrapEvent? = null - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - isDraft = false, - signer = sender, + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, ) { SealedRumorEvent.create( event = it, diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt index b18ca104f3..beb5573725 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt @@ -91,4 +91,19 @@ class HexBenchmark { fun isHex() { r.measureRepeated { Hex.isHex(hex) } } + + @Test + fun newIsHex() { + val isHexChar = + BooleanArray(256).apply { + "0123456789abcdefABCDEF".forEach { this[it.code] = true } + } + + r.measureRepeated { + for (c in hex.indices) { + if (!isHexChar[hex[c].code]) return@measureRepeated + } + return@measureRepeated + } + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt new file mode 100644 index 0000000000..3680267451 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.utils.RandomInstance +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.nio.charset.Charset + +@RunWith(AndroidJUnit4::class) +class HintIndexerBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object { + val keys = + mutableListOf().apply { + for (seed in 0..1_000_000) { + add(RandomInstance.bytes(32).toHexKey()) + } + } + + val relays = + getInstrumentation() + .context.assets + .open("relayDB.txt") + .readBytes() + .toString(Charset.forName("utf-8")) + .split("\n") + } + + @Test + fun relayUriHashcode() { + benchmarkRule.measureRepeated { + "wss://relay.bitcoin.social".hashCode() + } + } + + @Test + fun getRelayHints() { + val indexer = HintIndexer() + + keys.forEach { key -> + (0..5).map { + indexer.addKey(key, relays.random()) + } + } + + val key = keys.random() + + benchmarkRule.measureRepeated { + indexer.getKey(key) + } + } + + @Test + fun buildIndexer() { + benchmarkRule.measureRepeated { + val indexer = HintIndexer() + keys.forEach { key -> + (0..5).map { + indexer.addKey(key, relays.random()) + } + } + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt index 90ce0661b4..d26cf3613d 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt @@ -26,8 +26,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.commons.data.LargeCache -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import org.junit.Assert.assertTrue import org.junit.Rule diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/MurMurBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/MurMurBenchmark.kt new file mode 100644 index 0000000000..7f79cd35d1 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/MurMurBenchmark.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.hints.bloom.MurmurHash3 +import com.vitorpamplona.quartz.utils.RandomInstance +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MurMurBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + @Test + fun hash() { + val hasher = MurmurHash3() + + val byteArray = RandomInstance.bytes(32) + + benchmarkRule.measureRepeated { + hasher.hash(byteArray, 293) + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/PoWBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/PoWBenchmark.kt new file mode 100644 index 0000000000..fd3b2bc954 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/PoWBenchmark.kt @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Benchmark, which will execute on an Android device. + * + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. + */ +@RunWith(AndroidJUnit4::class) +class PoWBenchmark { + @get:Rule + val benchmarkRule = BenchmarkRule() + + val baseTemplate = + EventTemplate( + 1683596206, + TextNoteEvent.KIND, + arrayOf( + arrayOf("e", "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3", "", "root"), + arrayOf("e", "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc", "", "reply"), + arrayOf("p", "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"), + arrayOf("p", "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"), + arrayOf("p", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"), + arrayOf("p", "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"), + arrayOf("p", "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"), + arrayOf("p", "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"), + arrayOf("p", "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"), + arrayOf("p", "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"), + arrayOf("p", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", + ) + + @Test + fun generatePow() { + benchmarkRule.measureRepeated { + PoWMiner.run(baseTemplate, KeyPair().pubKey.toHexKey(), 5) + } + } + + @Test + fun setPoWCalculationHex() { + benchmarkRule.measureRepeated { + assertEquals(26, PoWRankEvaluator.calculatePowRankOf("00000026c91e9fc75fdb95b367776e2594b931cebda6d5ca3622501006669c9e")) + } + } + + @Test + fun setPoWCalculationBytes() { + val bytes = "00000026c91e9fc75fdb95b367776e2594b931cebda6d5ca3622501006669c9e".hexToByteArray() + benchmarkRule.measureRepeated { + assertEquals(26, PoWRankEvaluator.calculatePowRankOf(bytes)) + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RandomBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RandomBenchmark.kt new file mode 100644 index 0000000000..f230500e45 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RandomBenchmark.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.utils.RandomInstance +import junit.framework.TestCase.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RandomBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + @Test + fun random32Bytes() { + benchmarkRule.measureRepeated { assertNotNull(RandomInstance.bytes(32)) } + } + + @Test + fun random1000Bytes() { + benchmarkRule.measureRepeated { assertNotNull(RandomInstance.bytes(1000)) } + } + + @Test + fun randomInt() { + benchmarkRule.measureRepeated { assertNotNull(RandomInstance.int(1000)) } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Sha256Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Sha256Benchmark.kt index 3fbd26471d..5c176a8aa2 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Sha256Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Sha256Benchmark.kt @@ -23,13 +23,14 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.EventHasher import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.utils.sha256Hash +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.utils.sha256.sha256 import junit.framework.TestCase.assertNotNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.security.MessageDigest /** * Benchmark, which will execute on an Android device. @@ -43,13 +44,37 @@ class Sha256Benchmark { val benchmarkRule = BenchmarkRule() @Test - fun sha256() { + fun sha256Pool() { val event = Event.fromJson(largeKind1Event) val byteArray = EventHasher.makeJsonForId(event.pubKey, event.createdAt, event.kind, event.tags, event.content).toByteArray() benchmarkRule.measureRepeated { // Should pass - assertNotNull(sha256Hash(byteArray)) + assertNotNull(sha256(byteArray)) + } + } + + @Test + fun sha256NewEachTime() { + val event = Event.fromJson(largeKind1Event) + val byteArray = EventHasher.makeJsonForId(event.pubKey, event.createdAt, event.kind, event.tags, event.content).toByteArray() + + benchmarkRule.measureRepeated { + val digest = MessageDigest.getInstance("SHA-256") + assertNotNull(digest.digest(byteArray)) + } + } + + @Test + fun sha256Reuse() { + val event = Event.fromJson(largeKind1Event) + val byteArray = EventHasher.makeJsonForId(event.pubKey, event.createdAt, event.kind, event.tags, event.content).toByteArray() + + val digest = MessageDigest.getInstance("SHA-256") + + benchmarkRule.measureRepeated { + assertNotNull(digest.digest(byteArray)) + digest.reset() } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CryptoBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SharedKeyBenchmark.kt similarity index 61% rename from benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CryptoBenchmark.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SharedKeyBenchmark.kt index 2f35046d0b..2a2a9d3225 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CryptoBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SharedKeyBenchmark.kt @@ -23,15 +23,17 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip04Dm.crypto.Encryption +import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 import junit.framework.TestCase.assertNotNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class CryptoBenchmark { +class SharedKeyBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() @Test @@ -40,7 +42,7 @@ class CryptoBenchmark { val keyPair2 = KeyPair() benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.getSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) + assertNotNull(Nip04.getSharedSecret(keyPair1.privKey!!, keyPair2.pubKey)) } } @@ -48,9 +50,10 @@ class CryptoBenchmark { fun getSharedKeyNip44() { val keyPair1 = KeyPair() val keyPair2 = KeyPair() + val nip44v2 = Nip44v2() benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.nip44.v1.getSharedSecret(keyPair1.privKey!!, keyPair2.pubKey)) + assertNotNull(nip44v2.getConversationKey(keyPair1.privKey!!, keyPair2.pubKey)) } } @@ -58,9 +61,10 @@ class CryptoBenchmark { fun computeSharedKeyNip04() { val keyPair1 = KeyPair() val keyPair2 = KeyPair() + val nip04 = Encryption() benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.computeSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) + assertNotNull(nip04.computeSharedSecret(keyPair1.privKey!!, keyPair2.pubKey)) } } @@ -68,40 +72,10 @@ class CryptoBenchmark { fun computeSharedKeyNip44() { val keyPair1 = KeyPair() val keyPair2 = KeyPair() + val nip44v2 = Nip44v2() benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.nip44.v1.computeSharedSecret(keyPair1.privKey!!, keyPair2.pubKey)) - } - } - - @Test - fun random() { - benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.random(1000)) } - } - - @Test - fun sha256() { - val keyPair = KeyPair() - - benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.sha256(keyPair.pubKey)) } - } - - @Test - fun sign() { - val keyPair = KeyPair() - val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) - - benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.sign(msg, keyPair.privKey!!)) } - } - - @Test - fun verify() { - val keyPair = KeyPair() - val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) - val signature = CryptoUtils.sign(msg, keyPair.privKey!!) - - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.verifySignature(signature, msg, keyPair.pubKey)) + assertNotNull(nip44v2.computeConversationKey(keyPair1.privKey!!, keyPair2.pubKey)) } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt new file mode 100644 index 0000000000..288995e945 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SignVerifyBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + @Test + fun sign() { + val keyPair = KeyPair() + val msg = sha256(RandomInstance.bytes(1000)) + + benchmarkRule.measureRepeated { assertNotNull(Nip01.sign(msg, keyPair.privKey!!)) } + } + + @Test + fun verify() { + val keyPair = KeyPair() + val msg = sha256(RandomInstance.bytes(1000)) + val signature = Nip01.sign(msg, keyPair.privKey!!) + + benchmarkRule.measureRepeated { + assertTrue(Nip01.verify(signature, msg, keyPair.pubKey)) + } + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt index ffc4f2a0aa..e9cca78b36 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.compose import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import kotlin.math.max +import kotlin.math.min fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue { var toInsert = url.trim() @@ -41,3 +43,54 @@ fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue { TextRange(endOfUrlIndex, endOfUrlIndex), ) } + +fun TextFieldValue.replaceCurrentWord(wordToInsert: String): TextFieldValue { + val lastWordStart = currentWordStartIdx() + val lastWordEnd = currentWordEndIdx() + val cursor = lastWordStart + wordToInsert.length + return TextFieldValue( + text.replaceRange(lastWordStart, lastWordEnd, wordToInsert), + TextRange(cursor, cursor), + ) +} + +fun TextFieldValue.currentWordStartIdx(): Int { + val previousNewLine = text.lastIndexOf('\n', selection.start - 1) + val previousSpace = text.lastIndexOf(' ', selection.start - 1) + + return max( + previousNewLine, + previousSpace, + ) + 1 +} + +fun TextFieldValue.currentWordEndIdx(): Int { + val nextNewLine = text.indexOf('\n', selection.end) + val nextSpace = text.indexOf(' ', selection.end) + + if (nextSpace < 0 && nextNewLine < 0) return selection.end + if (nextSpace > 0 && nextNewLine > 0) { + return min( + nextNewLine, + nextSpace, + ) + } + if (nextSpace > 0) { + return nextSpace + } + return nextNewLine +} + +fun TextFieldValue.currentWord(): String { + if (selection.end != selection.start) return "" + + val start = currentWordStartIdx() + val end = currentWordEndIdx() + + return if (start < end) { + val word = text.substring(start, end) + word + } else { + "" + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt index 984de380ed..2fa763348d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.data -import com.vitorpamplona.quartz.nip01Core.HexKey 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.nip09Deletions.DeletionEvent class DeletionIndex { diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt new file mode 100644 index 0000000000..d3ddf3e971 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.emojicoder + +object EmojiCoder { + // Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html + // VS1..=VS16 + const val VARIATION_SELECTOR_START = 0xfe00 + const val VARIATION_SELECTOR_END = 0xfe0f + + // Variation selectors supplement https://unicode.org/charts/nameslist/n_E0100.html + // VS17..=VS256 + const val VARIATION_SELECTOR_SUPPLEMENT_START = 0xe0100 + const val VARIATION_SELECTOR_SUPPLEMENT_END = 0xe01ef + + val toVariationArray = + Array(256) { + // converts to UTF-16. Always char[2] back + when (it) { + in 0..15 -> Character.toChars(VARIATION_SELECTOR_START + it) + in 16..255 -> Character.toChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16) + else -> throw RuntimeException("This should never happen") + } + } + + fun fromVariationSelector(codePoint: Int): Int? = + when (codePoint) { + in VARIATION_SELECTOR_START..VARIATION_SELECTOR_END -> codePoint - VARIATION_SELECTOR_START + in VARIATION_SELECTOR_SUPPLEMENT_START..VARIATION_SELECTOR_SUPPLEMENT_END -> codePoint - VARIATION_SELECTOR_SUPPLEMENT_START + 16 + else -> null + } + + fun isVariationChar(charCode: Int) = + charCode in VARIATION_SELECTOR_START..VARIATION_SELECTOR_END || + charCode in VARIATION_SELECTOR_SUPPLEMENT_START..VARIATION_SELECTOR_SUPPLEMENT_END + + @JvmStatic + fun isCoded(text: String): Boolean { + if (text.length <= 3) return false + + if (!isVariationChar(text.codePointAt(text.length - 2))) { + return false + } + + if (text.length > 4 && !isVariationChar(text.codePointAt(text.length - 4))) { + return false + } + + return true + } + + @JvmStatic + fun encode( + emoji: String, + text: String, + ): String { + val input = text.toByteArray(Charsets.UTF_8) + val out = CharArray(input.size * 2) + var outIdx = 0 + for (i in 0 until input.size) { + val chars = toVariationArray[input[i].toInt() and 0xFF] + out[outIdx++] = chars[0] + out[outIdx++] = chars[1] + } + return emoji + String(out) + } + + @JvmStatic + fun decode(text: String): String { + val decoded = mutableListOf() + + var i = 0 + while (i < text.length) { + val codePoint = text.codePointAt(i) + val byte = fromVariationSelector(codePoint) + + if (byte == null && decoded.isNotEmpty()) { + break + } else if (byte == null) { + i += Character.charCount(codePoint) // Advance index by correct number of chars + continue + } + + decoded.add(byte) + i += Character.charCount(codePoint) // Advance index by correct number of chars + } + + val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() } + return String(decodedArray, Charsets.UTF_8) + } + + @JvmStatic + fun cropToFirstMessage(text: String): String { + val decoded = mutableListOf() + + var i = 0 + while (i < text.length) { + val codePoint = text.codePointAt(i) + val byte = fromVariationSelector(codePoint) + + if (byte == null && decoded.isNotEmpty()) { + break + } else if (byte == null) { + i += Character.charCount(codePoint) // Advance index by correct number of chars + continue + } + + decoded.add(byte) + i += Character.charCount(codePoint) // Advance index by correct number of chars + } + + return text.substring(0, i) + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 15255f8cd0..94f03e8201 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -21,13 +21,13 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import java.io.File @Immutable abstract class BaseMediaContent( val description: String? = null, - val dim: Dimension? = null, + val dim: DimensionTag? = null, val blurhash: String? = null, ) @@ -36,7 +36,7 @@ abstract class MediaUrlContent( val url: String, description: String? = null, val hash: String? = null, - dim: Dimension? = null, + dim: DimensionTag? = null, blurhash: String? = null, val uri: String? = null, val mimeType: String? = null, @@ -48,7 +48,7 @@ open class MediaUrlImage( description: String? = null, hash: String? = null, blurhash: String? = null, - dim: Dimension? = null, + dim: DimensionTag? = null, uri: String? = null, val contentWarning: String? = null, mimeType: String? = null, @@ -59,7 +59,7 @@ class EncryptedMediaUrlImage( description: String? = null, hash: String? = null, blurhash: String? = null, - dim: Dimension? = null, + dim: DimensionTag? = null, uri: String? = null, contentWarning: String? = null, mimeType: String? = null, @@ -73,7 +73,7 @@ open class MediaUrlVideo( url: String, description: String? = null, hash: String? = null, - dim: Dimension? = null, + dim: DimensionTag? = null, uri: String? = null, val artworkUri: String? = null, val authorName: String? = null, @@ -87,7 +87,7 @@ class EncryptedMediaUrlVideo( url: String, description: String? = null, hash: String? = null, - dim: Dimension? = null, + dim: DimensionTag? = null, uri: String? = null, artworkUri: String? = null, authorName: String? = null, @@ -105,7 +105,7 @@ abstract class MediaPreloadedContent( description: String? = null, val mimeType: String? = null, val isVerified: Boolean? = null, - dim: Dimension? = null, + dim: DimensionTag? = null, blurhash: String? = null, val uri: String, val id: String? = null, @@ -118,7 +118,7 @@ class MediaLocalImage( localFile: File?, mimeType: String? = null, description: String? = null, - dim: Dimension? = null, + dim: DimensionTag? = null, blurhash: String? = null, isVerified: Boolean? = null, uri: String, @@ -129,7 +129,7 @@ class MediaLocalVideo( localFile: File?, mimeType: String? = null, description: String? = null, - dim: Dimension? = null, + dim: DimensionTag? = null, blurhash: String? = null, isVerified: Boolean? = null, uri: String, diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index c7b4584b7e..398e3eb5b9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -24,13 +24,18 @@ import android.util.Log import android.util.Patterns import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip36SensitiveContent.CONTENT_WARNING -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -46,14 +51,15 @@ import kotlin.coroutines.cancellation.CancellationException class RichTextParser { fun createMediaContent( fullUrl: String, - eventTags: ImmutableListOfLists, + eventTags: Map, description: String?, callbackUri: String? = null, ): MediaUrlContent? { val frags = Nip54InlineMetadata().parse(fullUrl) - val tags = Nip92MediaAttachments.parse(fullUrl, eventTags.lists) - val contentType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE] + val tags = eventTags.get(fullUrl)?.properties ?: emptyMap() + + val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull() val isImage: Boolean val isVideo: Boolean @@ -73,22 +79,22 @@ class RichTextParser { return if (isImage) { MediaUrlImage( url = fullUrl, - description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], - hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], - blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], - dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) }, - contentWarning = frags[CONTENT_WARNING] ?: tags[CONTENT_WARNING], + description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), + hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(), + blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(), + dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) }, + contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(), uri = callbackUri, mimeType = contentType, ) } else if (isVideo) { MediaUrlVideo( url = fullUrl, - description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], - hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], - blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], - dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) }, - contentWarning = frags[CONTENT_WARNING] ?: tags[CONTENT_WARNING], + description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), + hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(), + blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(), + dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) }, + contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(), uri = callbackUri, mimeType = contentType, ) @@ -131,10 +137,11 @@ class RichTextParser { tags: ImmutableListOfLists, callbackUri: String?, ): RichTextViewerState { + val imetas = tags.lists.imetasByUrl() val urlSet = parseValidUrls(content) val imagesForPager = - urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, tags, content, callbackUri) }.associateBy { it.url } + urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, imetas, content, callbackUri) }.associateBy { it.url } val emojiMap = CustomEmoji.createEmojiMap(tags) @@ -145,7 +152,7 @@ class RichTextParser { val imagesForPagerWithBase64 = imagesForPager + base64Images - .mapNotNull { createMediaContent(it.segmentText, tags, content, callbackUri) } + .mapNotNull { createMediaContent(it.segmentText, emptyMap(), content, callbackUri) } .associateBy { it.url } return RichTextViewerState( @@ -154,6 +161,7 @@ class RichTextParser { imagesForPagerWithBase64.values.toImmutableList(), emojiMap.toImmutableMap(), segments, + tags, ) } @@ -249,6 +257,8 @@ class RichTextParser { if (word.startsWith("#")) return parseHash(word, tags) + if (EmojiCoder.isCoded(word)) return SecretEmoji(word) + if (word.contains("@")) { if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) return EmailSegment(word) } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index c4238e32a4..e794e50ceb 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -21,17 +21,19 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableSet @Immutable -data class RichTextViewerState( +class RichTextViewerState( val urlSet: ImmutableSet, val imagesForPager: ImmutableMap, val imageList: ImmutableList, val customEmoji: ImmutableMap, val paragraphs: ImmutableList, + val tags: ImmutableListOfLists, ) @Immutable @@ -80,6 +82,10 @@ class EmailSegment( segment: String, ) : Segment(segment) +class SecretEmoji( + segment: String, +) : Segment(segment) + @Immutable class PhoneSegment( segment: String, diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index a07bb1f8db..9c0402dda8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -85,8 +85,8 @@ import com.vitorpamplona.amethyst.commons.robohash.parts.mouth6Cell import com.vitorpamplona.amethyst.commons.robohash.parts.mouth7Happy import com.vitorpamplona.amethyst.commons.robohash.parts.mouth8Buttons import com.vitorpamplona.amethyst.commons.robohash.parts.mouth9Closed -import com.vitorpamplona.quartz.CryptoUtils import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.sha256.sha256 val Black = SolidColor(Color.Black) val Gray = SolidColor(Color(0xFF6d6e70)) @@ -168,7 +168,7 @@ class RobohashAssembler { Hex.decode(msg) } else { Log.w("Robohash", "$msg is not a hex") - CryptoUtils.sha256(msg.toByteArray()) + sha256(msg.toByteArray()) } val bgColor = SolidColor(bytesToColor(hash[0], hash[1], hash[2], isLightTheme)) diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt b/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt new file mode 100644 index 0000000000..5157378bd2 --- /dev/null +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.emojicoder + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EmojiCoderTest { + companion object { + val EMOJI_LIST = arrayOf("😀", "😂", "🥰", "😎", "🤔", "👍", "👎", "👏", "😅", "🤝", "🎉", "🎂", "🍕", "🌈", "🌞", "🌙", "🔥", "💯", "🚀", "👀", "💀", "🥹") + + val testStrings = + arrayOf( + "Hello, World!", + "Testing 123", + "Special chars: !@#$%^&*()", + "Unicode: 你好,世界", + " ", // space only + ) + + val HELLO_WORLD = "\uD83D\uDE00\uDB40\uDD38\uDB40\uDD55\uDB40\uDD5C\uDB40\uDD5C\uDB40\uDD5F\uDB40\uDD1C\uDB40\uDD10\uDB40\uDD47\uDB40\uDD5F\uDB40\uDD62\uDB40\uDD5C\uDB40\uDD54\uDB40\uDD11" + val HELLO_WORLD_WITH_EXTRAS = HELLO_WORLD + "askfasdf" + } + + @Test + fun testIsCoded() { + assertEquals(true, EmojiCoder.isCoded(HELLO_WORLD)) + } + + @Test + fun testEncode() { + assertEquals(HELLO_WORLD, EmojiCoder.encode("\uD83D\uDE00", "Hello, World!")) + } + + @Test + fun testDecode() { + assertEquals("Hello, World!", EmojiCoder.decode(HELLO_WORLD)) + } + + @Test + fun testCrop() { + assertEquals(HELLO_WORLD, EmojiCoder.cropToFirstMessage(HELLO_WORLD_WITH_EXTRAS)) + } + + @Test + fun testEncodeDecode() { + for (emoji in EMOJI_LIST) { + for (sentence in testStrings) { + val encoded = EmojiCoder.encode(emoji, sentence) + val decoded = EmojiCoder.decode(encoded) + assertEquals(sentence, decoded) + assertTrue("Failed sentence for emoji $emoji with sentence `$sentence`: `$encoded`", EmojiCoder.isCoded(encoded)) + } + } + } + + @Test + fun testLinkFromAmethyst() { + assertEquals("https://cdn.satellite.earth/947e4ab2d3115be565a49cf5db02559f310ca0a6ddfddbd4bd8cbec44995c2e7.webp", EmojiCoder.decode("🚀󠅘󠅤󠅤󠅠󠅣󠄪󠄟󠄟󠅓󠅔󠅞󠄞󠅣󠅑󠅤󠅕󠅜󠅜󠅙󠅤󠅕󠄞󠅕󠅑󠅢󠅤󠅘󠄟󠄩󠄤󠄧󠅕󠄤󠅑󠅒󠄢󠅔󠄣󠄡󠄡󠄥󠅒󠅕󠄥󠄦󠄥󠅑󠄤󠄩󠅓󠅖󠄥󠅔󠅒󠄠󠄢󠄥󠄥󠄩󠅖󠄣󠄡󠄠󠅓󠅑󠄠󠅑󠄦󠅔󠅔󠅖󠅔󠅔󠅒󠅔󠄤󠅒󠅔󠄨󠅓󠅒󠅕󠅓󠄤󠄤󠄩󠄩󠄥󠅓󠄢󠅕󠄧󠄞󠅧󠅕󠅒󠅠")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 448648f3a9..322b3ba5ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -accompanistAdaptive = "0.37.0" -activityCompose = "1.9.3" -agp = "8.8.0" +accompanistAdaptive = "0.37.2" +activityCompose = "1.10.1" +agp = "8.8.2" android-compileSdk = "35" android-minSdk = "26" android-targetSdk = "35" @@ -12,12 +12,12 @@ audiowaveform = "1.1.1" benchmark = "1.3.3" benchmarkJunit4 = "1.3.3" biometricKtx = "1.2.0-alpha05" -coil = "3.0.4" -composeBom = "2024.12.01" +coil = "3.1.0" +composeBom = "2025.02.00" coreKtx = "1.15.0" espressoCore = "3.6.1" -firebaseBom = "33.7.0" -fragmentKtx = "1.8.5" +firebaseBom = "33.9.0" +fragmentKtx = "1.8.6" gms = "4.4.2" jacksonModuleKotlin = "2.18.2" jna = "5.16.0" @@ -25,7 +25,7 @@ jtorctl = "0.4.5.7" junit = "4.13.2" kotlin = "2.1.0" kotlinxCollectionsImmutable = "0.3.8" -kotlinxSerialization = "1.7.3" +kotlinxSerialization = "1.8.0" kotlinxSerializationPlugin = "2.0.0" languageId = "17.0.6" lazysodiumAndroid = "5.1.0" @@ -33,22 +33,22 @@ lifecycleRuntimeKtx = "2.8.7" lightcompressor = "1.3.2" markdown = "077a2cde64" media3 = "1.5.1" -mockk = "1.13.14" +mockk = "1.13.16" kotlinx-coroutines-test = "1.10.1" -navigationCompose = "2.8.5" +navigationCompose = "2.8.8" okhttp = "5.0.0-alpha.14" runner = "1.6.2" rfc3986 = "0.1.2" -secp256k1KmpJniAndroid = "0.16.0" +secp256k1KmpJniAndroid = "0.17.1" securityCryptoKtx = "1.1.0-alpha06" spotless = "6.25.0" torAndroid = "0.4.8.12" translate = "17.0.3" unifiedpush = "2.3.1" urlDetector = "0.1.23" -vico-charts = "1.16.0" +vico-charts = "2.0.1" zelory = "3.0.1" -zoomable = "2.0.0" +zoomable = "2.1.0" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.3.0" @@ -121,6 +121,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } +secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" } diff --git a/quartz/build.gradle b/quartz/build.gradle index fb70e1d17f..ef440c1a91 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -77,6 +77,7 @@ dependencies { api libs.rfc3986.normalizer testImplementation libs.junit + testImplementation libs.secp256k1.kmp.jni.jvm androidTestImplementation platform(libs.androidx.compose.bom) androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.espresso.core diff --git a/quartz/src/androidTest/assets/relayDB.txt b/quartz/src/androidTest/assets/relayDB.txt new file mode 100644 index 0000000000..0977f44b3a --- /dev/null +++ b/quartz/src/androidTest/assets/relayDB.txt @@ -0,0 +1,2539 @@ +wss://nostr.wine +wss://relay.orangepill.dev +wss://xmr.usenostr.org +wss://nostr.portemonero.com +wss://nostr.xmr.rocks +wss://relay.nostr.band +wss://filter.nostr.wine +wss://nostr.milou.lol +wss://nostr.mutinywallet.com +wss://nostr-pub.wellorder.net +wss://nostr.zebedee.cloud +wss://nos.lol +wss://brb.io +wss://bitcoiner.social +wss://nostr.decentony.com +wss://relay.nostriches.org +wss://paid.spore.ws +wss://eden.nostr.land +wss://puravida.nostr.land +wss://5dzvuefllevkhk7miqynaviguedxfnofrayu2xwfwtlkdg4radjdlyqd.onion +wss://relay-jp.nostr.wirednet.jp +wss://relay.nostrich.land +wss://nostr.holybea.com +wss://nostr-relay.nokotaro.com +wss://nostr-paid.h3z.jp +wss://nostrja-kari.heguro.com +wss://nostr.mom +wss://nostr.fediverse.jp +wss://nostr.h3z.jp +wss://universe.nostrich.land +wss://nostr.uselessshit.co +wss://atlas.nostr.land +wss://relay.snort.social +wss://universe.nostrich.landlangenlanges +wss://nostr.slothy.win +wss://nostr.plebchain.org +wss://nostr-relay.untethr.me +wss://relay.nostr.com.au +wss://nostr.inosta.cc +wss://relay.nostrati.com +wss://nostr.bitcoiner.social +wss://relay.nostrplebs.com +wss://relay.nostr.info +wss://nostr-relay.wlvs.space +wss://nostr.oxtr.dev +wss://nostr.onsats.org +wss://relay.wellorder.net +wss://relay.plebstr.com +wss://no.str.cr +wss://nostr.walletofsatoshi.com +wss://nostr.mwmdev.com +wss://relay.nostr.bg +wss://nostr.rocks +wss://nostr.fmt.wiz.biz +wss://nostr.orangepill.dev +wss://nostr-pub.semisol.dev +wss://nostr.sandwich.farm +wss://relay.nostr.ch +wss://relay.orange-crush.com +wss://private.red.gb.net +wss://nostr.lnprivate.network +wss://nostr.lu.ke +wss://relay.nostr.wirednet.jp +wss://lightningrelay.com +wss://relay.nostrgraph.net +wss://relay.nostrica.com +wss://relay.mostr.pub +wss://nostr-sg.com +wss://nostr.zkid.social +wss://relay.nostr.vet +wss://relay.nostr3.io +wss://relay.arsip.my.id +wss://relay.current.fyi +wss://global.relay.red +wss://nostr.island.network +wss://node01.nostress.cc +wss://relay.nostr.net.in +wss://relay.utxo.one +wss://relay-1.arsip.my.id +wss://nostrical.com +wss://nostro.cc +wss://rsslay.nostr.moe +wss://relay.nostr.or.jp +wss://nostream.unift.xyz +wss://blastr.f7z.xyz +wss://relay.honk.pw +wss://universe.nostrich.landlangja +wss://nostream.ocha.one +wss://paid-relay.nost.love +wss://offchain.pub +wss://nostr-usa.ka1gbeoa21bnm.us-west-2.cs.amazonlightsail.com +wss://nostr.terminus.money +wss://nostr.shawnyeager.net +wss://public.nostr.swissrouting.com +wss://nostrex.fly.dev +wss://relay.727whisky.com +wss://relay.cryptocculture.com +wss://relay.bleskop.com +wss://nostr.lorentz.is +wss://nostr.actn.io +wss://nostr-relay.lnmarkets.com +wss://nostr.openchain.fr +wss://relay.punkhub.me +wss://nostr.blipme.app +wss://nostr.swiss-enigma.ch +wss://nostr-verified.wellorder.net +wss://at.nostrworks.com +wss://21sats.net +wss://e.nos.lol +wss://nostr.mikedilger.com +wss://nostr.azte.co +wss://noster.bitcoiner.social +wss://relay.nostr.moe +wss://nostream.nostrly.io +wss://nostr.gives.africa +wss://nostr.21sats.net +wss://bitcoinmaximalists.online +wss://paid.nostrified.org +wss://nostr.1sat.org +wss://nostr.relayer.se +wss://sg.qemura.xyz +wss://nostr.coinos.io +wss://nostr.bitcoinplebs.de +wss://nostrich.friendship.tw +wss://nostr.sg +wss://eosla.com +wss://nostr.sidnlabs.nl +wss://nostr.foundrydigital.com +wss://relay.nostrview.com +wss://nostr.semisol.dev +wss://relay.f7z.io +wss://wlvs.space +wss://nostr.v0l.io +wss://nostr-relay.digitalmob.ro +wss://rsslay.fiatjaf.com +wss://relay.theorangepillapp.com +wss://relay.zeh.app +wss://nostr.zoomout.chat +wss://relay.stoner.com +wss://nostr.cercatrova.me +wss://relay.ryzizub.com +wss://nostr-1.nbo.angani.co +wss://nostr21.com +wss://spore.ws +wss://nostrue.com +wss://no-str.org +wss://relay.taxi +wss://ragnar-relay.com +wss://relay.austrich.net +wss://relay.nostr-latam.link +wss://1.noztr.com +wss://relay.nostr.scot +wss://test.relay.nostrich.day +wss://jiggytom.ddns.net +wss://nostr.bongbong.com +wss://relay.nostromo.social +wss://relay.sendstr.com +wss://nostr-dev.universalname.space +wss://relay.nostrcheck.me +wss://nostr.libertasprimordium.com +wss://nostr.kollider.xyz +wss://expensive-relay.fiatjaf.com +wss://nostr-sandbox.minds.io +wss://relay.nostrich.de +wss://nostr.gromeul.eu +wss://relay.nostr.wine +wss://nostr.screaminglife.io +wss://nostr-relay.derekross.me +wss://nostrica.nostrnotes.com +wss://paid.no.str.cr +wss://nostr.sethforprivacy.com +wss://nostr.dumpit.top +wss://nostr.herci.one +wss://cheery-paddock-rsakdrtc35c55n6yregn.wnext.app +wss://nostr.blockpower.capital +wss://nostr.nym.life +wss://nostr-verif.slothy.win +wss://fiatdenier.com +wss://nostr.bitcoin-21.org +wss://nostr.fluidtrack.in +wss://nostr.developer.li +wss://r.ayit.org +wss://relay.nostr.nu +wss://nostr.bostonbtc.com +wss://rly.social +wss://nostr.bridgey.dev +wss://relay.nostrprotocol.net +wss://nostr.mado.io +wss://nostr.einundzwanzig.space +wss://nostr2.actn.io +wss://nostr-relay.freedomnode.com +wss://nostr.pleb.network +wss://nostr.mouton.dev +wss://eelay.current.fyi +wss://nostr.notmyhostna.me +wss://nostr.pjv.me +wss://nostr.jatm.link +wss://nostr.fractalized.ovh +wss://nostr-relay.app.ikeji.ma +wss://relayer.ocha.one +wss://nostr.com.de +wss://nostr-2.afarazit.eu +wss://nostr.l00p.org +wss://nostr.drss.io +wss://relay.nostrify.io +wss://nostr.radixrat.com +wss://nostr-relay.bitcoin.ninja +wss://nostrsatva.net +wss://nostr.mustardnodes.com +wss://nostr01.vida.dev +wss://nostr.noones.com +wss://nostr.easify.de +wss://nostr3.actn.io +wss://moonbreeze.richardbondi.net +wss://nostr.naut.social +wss://private-nostr.v0l.io +wss://nostr.zaprite.io +wss://nostr.lightninglinks.xyz +wss://nostr.hackerman.pro +wss://nr.yay.so +wss://nostr.roundrockbitcoiners.com +wss://nostr.sovbit.host +wss://nostrelay.yeghro.site +wss://pow32.nostr.land +wss://nostr.1729.cloud +wss://nostr.rdfriedl.com +wss://nostr.h4x0r.host +wss://nostr.up.railway.app +wss://nostr.lnorb.com +wss://nostr.lordkno.ws +wss://relay.nostr.vision +wss://nostr-3.orba.ca +wss://satstacker.cloud +wss://freedom-relay.herokuapp.com +wss://nostr-relay.freeberty.net +wss://nostr.unknown.place +wss://nostr.delo.software +wss://relay.nostr.pro +wss://relay.minds.com +wss://nostr.ono.re +wss://relay.grunch.dev +wss://relay.cynsar.foundation +wss://relay.oldcity-bitcoiners.info +wss://relay.bitid.nz +wss://relay.nostr.xyz +wss://relay.futohq.com +wss://relay.farscapian.com +wss://astral.ninja +wss://relay.sovereign-stack.org +wss://nostr-2.zebedee.cloud +wss://nostr.nymsrelay.com +wss://relay.kronkltd.net +wss://relay.r3d.red +wss://universe.nostrich.landlangen +wss://nostr-dev.wellorder.net +wss://nostr.beta3.dev +wss://nostr.data.haus +wss://nostr.hugo.md +wss://relay-dev.cowdle.gg +wss://relay.dwadziesciajeden.pl +wss://tmp-relay.cesc.trade +wss://nostr.massmux.com +wss://relay.nostr.africa +wss://nostr1.tunnelsats.com +wss://nostr.f44.dev +wss://relay.n057r.club +wss://nostr-verif.slothy.com +wss://nostr.1f52b.xyz +wss://nostr.sebastix.dev +wss://nostr.lightning.contact +wss://nostr.rly.social +wss://noster.online +wss://relay.lexingtonbitcoin.org +wss://nostr.bitcoinbay.engineering +wss://nostr.howtobitcoin.shop +wss://blg.nostr.sx +wss://deschooling.us +wss://foolay.nostr.moe +wss://freespeech.casa +wss://nostr-01.bolt.observer +wss://nostr-01.dorafactory.org +wss://nostr-au.coinfundit.com +wss://nostr-eu.coinfundit.com +wss://nostr-relay.alekberg.net +wss://nostr-pub1.southflorida.ninja +wss://nostr-relay.gkbrk.com +wss://nostr-relay.pcdkd.fyi +wss://nostr-relay.schnitzel.world +wss://nostr-us.coinfundit.com +wss://nostr.21crypto.ch +wss://nostr.600.wtf +wss://nostr.8e23.net +wss://nostr.app.runonflux.io +wss://nostr.arguflow.gg +wss://nostr.bch.ninja +wss://nostr.chainofimmortals.net +wss://nostr.cizmar.net +wss://nostr.cheeserobot.org +wss://nostr.coollamer.com +wss://nostr.corebreach.com +wss://nostr.cro.social +wss://nostr.easydns.ca +wss://nostr.globals.fans +wss://nostr.handyjunky.com +wss://nostr.itas.li +wss://nostr.sectiontwo.org +wss://nostr.spleenrider.one +wss://nostr.thibautrey.fr +wss://nostr.uthark.com +wss://nostr.vulpem.com +wss://nostr.w3ird.tech +wss://nostr.whoop.ph +wss://nostr.yuv.al +wss://nostr01.opencult.com +wss://nostre.cc +wss://nostream.denizenid.com +wss://nostring.deno.dev +wss://pow.nostrati.com +wss://relay-pub.deschooling.us +wss://nostr.jiashanlu.synology.me +wss://nostr.klabo.blog +wss://relay.valireum.net +wss://nostr.fly.dev +wss://nostr.nordlysln.net +wss://nostr.zerofeerouting.com +wss://rsslay.nostr.net +wss://nostr-relay.nonce.academy +wss://nostr.rewardsbunny.com +wss://lv01.tater.ninja +wss://nostr-2.orba.ca +wss://nostr.orba.ca +wss://nostr.supremestack.xyz +wss://nostrrelay.com +wss://relay.nostr.au +wss://nostr.oooxxx.ml +wss://nostr.yael.at +wss://nostr-relay.trustbtc.org +wss://nostr.namek.link +wss://nostr-relay.wolfandcrow.tech +wss://nostr.satsophone.tk +wss://relay.dev.kronkltd.net +wss://nostr2.namek.link +wss://relay.21spirits.io +wss://relay.minds.io +wss://nostr.d11n.net +wss://nostr.tunnelsats.com +wss://nostr.leximaster.com +wss://mule.platanito.org +wss://nostr.robotechy.com +wss://relay.nostrmoto.xyz +wss://relay.boring.surf +wss://nostr.gruntwerk.org +wss://nostr.hyperlingo.com +wss://nostr.ethtozero.fr +wss://nostr.nodeofsven.com +wss://nostr.jimc.me +wss://nostr.utxo.lol +wss://relay.nyx.ma +wss://nostr.shmueli.org +wss://wizards.wormrobot.org +wss://nostr.sovbit.com +wss://nostr.datamagik.com +wss://relay.nostrid.com +wss://nostr1.starbackr.me +wss://relay.nostr.express +wss://nostr.formigator.eu +wss://nostr.xpersona.net +wss://nostr.digitalreformation.info +wss://nostr-relay.usebitcoin.space +wss://nostr-alpha.gruntwerk.org +wss://nostr-relay.australiaeast.cloudapp.azure.com +wss://nostr-relay.smoove.net +wss://nostr-relay.j3s7m4n.com +wss://nostr.demovement.net +wss://nostr.thesimplekid.com +wss://nostr.aozing.com +wss://nostr.blocs.fr +wss://no.str.watch +wss://btc.klendazu.com +wss://nostr.mrbits.it +wss://nostr.zenon.wtf +wss://no.contry.xyz +wss://nostream.gromeul.eu +wss://relay.nostr.ro +wss://nostr.ncsa.illinois.edu +wss://nostr.itssilvestre.com +wss://nostr.chaker.net +wss://knostr.neutrine.com +wss://nostr.pobblelabs.org +wss://nostr.simatime.com +wss://relay.nosphr.com +wss://student.chadpolytechnic.com +wss://nostr.localhost.re +wss://nostr.coinsamba.com.br +wss://deconomy-netser.ddns.net:2121 +wss://nostr.21m.fr +wss://zur.nostr.sx +wss://nostr-relay.texashedge.xyz +wss://spleenrider.herokuapp.com +wss://nostr.bitcoin.sex +wss://relay.nostrzoo.com +wss://nostr.blockchaincaffe.it +wss://nostr-bg01.ciph.rs +wss://knostr.neutrine.com:8880 +wss://nostr.ahaspharos.de +wss://nostr.argdx.net +wss://nostr.snblago.com +wss://merrcurr.up.railway.app +wss://nostr.bingtech.tk +wss://relay.nostr.wf +wss://relay.koreus.social +wss://nostr.randomdevelopment.biz +wss://relay.nostr.hu +wss://relay.nostr.lu +wss://relay.nostr.ae +wss://middling.myddns.me:8080 +wss://nostr.nikolaj.online +wss://relay.nostrology.org +wss://nostr.satoshi.fun +wss://nostream.kinchie.snowinning.com +wss://nostr.lapalomilla.mx +wss://relay.thes.ai +wss://rsr.uyky.net:30443 +wss://nostrafrica.pcdkd.fyi +wss://nostr.bitcoin-basel.ch +wss://relay.21baiwan.com +wss://nostr.ddns.net:8008 +wss://free-relay.nostrich.land +wss://nostr.lukeacl.com +wss://nostr.ddns.net +wss://nostr.rocket-tech.net +wss://nostr-1.afarazit.eu +wss://nostr.0nyx.eu +wss://nostr-mv.ashiroid.com +wss://lbrygen.xyz +wss://nostr.community.networks.deavmi.assigned.network +wss://nostr.ownscale.org +wss://relay1.gems.xyz +wss://nostr.soscary.net +wss://nostr.0xtr.dev +wss://damus.io +wss://relay.alien.blue +wss://nostr.btcmp.com +wss://relayer.fiatjaf.com +wss://relay.lacosanostr.com +wss://adult.18plus.social +wss://nostrrr.bublina.eu.org +wss://relay.stoner +wss://nostr.pwnshop.cloud +wss://nostr.directory +wss://nostr-relay-dev.wlvs.space +wss://member.cash +wss://relay.nyc1.vinux.app +wss://nostr-relay.digitamob.ro +wss://nor.st +wss://nostr.topeth.info +wss://nostr.rocketstyle.com.au +wss://relay.tnano.duckdns.org +wss://nostr.21l.st +wss://electra.nostr.land +wss://relay.codl.co +wss://nostr.koning-degraaf.nl +wss://relay.mrjohnsson.net +wss://nostr.thank.eu +wss://relay.stonez.me +wss://relay.nostr.distrl.net +wss://relay.valera.co +wss://api.semisol.dev +wss://nostr.lol +wss://relay.shitforce.one +wss://n-word.sharivegas.com +wss://lamp.wtf +wss://nostr.bitcoinpuertori.co +wss://nostr-01.bolt.oberver +wss://3d515c5277e9.ngrok.io +wss://nostr.xmrk.mooo.com +wss://alphapanda.pro +wss://relays.world +wss://universe.nostrich.landlangzh +wss://arnostr.permadao.io +wss://relay.chenxixian.cn +wss://universe.nostrich.landlangzhlangen +wss://v2r.chenxixian.cn +wss://nostr-relay-test.nokotaro.work +wss://universe.nostrich.landlangjalangen +wss://nostr.risa.zone +wss://relay.nosbin.com +wss://translate.argosopentech.com +wss://edennostr.land +wss://nostr.kawagarbo.xyz +wss://nostr.member.cash +wss://ch1.duno.com +wss://nostream-production-b80e.up.railway.app +wss://relay1.nostrich.cloud +wss://relay.t5y.ca +wss://nostr.zhongwen.world +wss://nostr.p2sh.co +wss://nostr.thomascdnns.com +wss://nostream.simon.snowinning.com +wss://relay.nostr.blockhenge.com +wss://nostr.buythisdip.com +wss://nostrua.com +wss://relay.bigred.social +wss://lingoh.dev +wss://nostr.poster.place +wss://nostr.geekgalaxy.com +wss://oarnx6xdrq5mygfdrbmzsvh3is3holefpz2x4qwbopwcicwd63gcivid.onion +wss://relay.nostropolis.xyz +wss://nostream-production-ba43.up.railway.app +wss://nostr.sabross.xyz +wss://relay.nvote.co +wss://nostrati.com +wss://cloudnull.land +wss://nostr.frennet.xyz +wss://nostr.wine.com +wss://nostr.sactiontwo.org +wss://nostr.liberty.fans +wss://nostr.primz.org +wss://btc-italia.online +wss://homenode.local:4848 +wss://nostr.frennet.xyzl +wss://relay.roosoft.com +wss://rasca.asnubes.art +wss://nostr.bitcoin.sexanewlycre +wss://nostr.barf.bz +wss://nostr.middling.mydns.jp +wss://relay.xuzmail.com +wss://no-str.wnhefei.cn:28443 +wss://quirky-bunch-isubghsvoi26fbbt3n7o.wnext.app +wss://nostr.fennel.org:7000 +wss://nostr.0ne.day +wss://nostr.vpn1.codingmerc.com +wss://nostr.jacany.com +wss://nostream.lucas.snowinning.com +wss://relay.beta.fogtype.com +wss://nostr.zue.news +wss://nostream.madbean.snowinning.com +wss://nostr2.rbel.co +wss://relay.1bps.io +wss://nostream-relay-nostr.831.pp.ua +wss://zee-relay.fly.dev +wss://nostrrelay.geforcy.com +wss://relay.nostr.jhot.me +wss://nostr.itredneck.com +wss://nostr.h3y6e.com +wss://relay.bitcoiner.social +wss://hos.lol +wss://iris.to +wss://nostr-pub.senisol.dev +wss://nostr-pub.wellirder.net +wss://bitcoinforthe.lol +wss://relav.nostr.info +wss://3e32-200-229-144-129.ngrok.io +wss://nostr-relay.hzrd149.com +wss://nostr-world.h3z.jp +wss://nostr.thesamecat.io +wss://nostr.compile-error.net +wss://relayable.org +wss://mostra.milou.lol +wss://nproxy.cc +wss://nostr.bitmatk.io +wss://coracle.social +wss://umbrel.local:4848 +wss://nostr.ownbtc.online +wss://wss.nostrgram.co:444 +wss://nostr.minimue81.selfhost.co +wss://relay.current.fy +wss://nostream.nostr.parts +wss://nostr.zebede.cloud +wss://nostrwhoop.ph +wss://relay.nostrified.org +wss://nproxy.zerologin.co +wss://nostr.pcdkd.fyi +wss://relay.kongerik.et +wss://nostr.eden.land +wss://nostr.retroware.run.place +wss://relay.humanumest.social +wss://bhagos.org +wss://hushvault.ie +wss://nostream-production-9458.up.railway.app +wss://nostr.nakamotosatoshi.cf +wss://globals.fans +wss://nostr.cruncher.com +wss://nostr.global.fan +wss://relay.nostr.snblago.com +wss://nostr.nokotaro.com +wss://ostr-1.afarazit.eu +wss://relay.zhix.in +wss://stats.nostr.band +wss://nostr.fine +wss://vxlw4rlg7go34ol43g4gxbvfu4txdzjauquvnbptzwjflezs3vik55id.onion +wss://big.fist.black +wss://universe.nostrich.landlangzhlangja +wss://relay.plebz.space +wss://nostrich.land +wss://relay.mynostr.fun +wss://swiss-enigma.ch +wss://nostr1.current.fyi +wss://relay.atlas.nostr.land +wss://nostr.band +wss://n.wingu.se +wss://nostr.jmdtx.com +wss://nostrproxy-1.f7z.io +wss://nostr.ch +wss://roundrockbitcoiners.com +wss://nostr.sept.ml +wss://srelay.roli.social +wss://nostr.monostr.com +wss://nostr.dojotunnel.online +wss://nostrica.dojotunnel.online +wss://nostr.shadownode.org +wss://thes.ai +wss://rsslay.wss +wss://nostr.vol.io +wss://nostrgram.co +wss://habla.news +wss://runningnostr.lol +wss://mostr.pub +wss://relay.example2.com +wss://profiles.f7z.io +wss://nostr.adpo.co +wss://jp-relay-nostr.invr.chat +wss://nostr.anchel.nl +wss://mutinywallet.com +wss://relay.nostrbr.online +wss://filter.eden.nostr.land +wss://relay.nostrdocs.com +wss://relay.nostr.lucentlabs.co +wss://n.xmr.se +wss://nostr.relayer.rs +wss://monad.jb55.com:8080 +wss://nostr.watch +wss://universe.nostrich.landlangenlangzhlangja +wss://nostr.asdf.mx +wss://ts.relays.world +wss://arc1.arcadelabs.co +wss://stealth.wine +wss://nostr.bg +wss://really.nostr.bg +wss://realy.nostr.bg +wss://relai.nostr.bg +wss://relay-verified.deschooling.us +wss://4.up.railway.app +wss://nostr-relay.aapi.me +wss://nostr-z9tc.onrender.com +wss://nostrich.site +wss://nostr.ginuerzh.xyz +wss://310b-200-229-144-129.ngrok.io +wss://nostr.aste.co +wss://black.nostrcity.club +wss://nostr.guru +wss://nostrica.com +wss://relay.leesalminen.com +wss://nostr.shroomslab.net +wss://meta-relay-beta.nostr.wirednet.jp +wss://nostr.reamde.dev +wss://nostr.africa +wss://powrelay.xyz +wss://rsslay.data.haus +wss://nostr.danvergara.com +wss://nostr.one.re +wss://dev2.hazilitt.fiatjaf.com +wss://nostr-2.zebdeee.cloud +wss://zerosequioso.com +wss://brb.io.relay +wss://relay.realsearch.cc +wss://nodestr.fmt.wiz.biz +wss://r.alphaama.com +wss://nostr.relay-wlvs.space +wss://relay.21spiritis.io +wss://nostr.pinkanki.org +wss://nostr.damus.io +wss://fin-nostr.seekdisruption.com +wss://relay.nostr.lu.ke +wss://nostr.rdfried.com +wss://nostr.trustbtc.org +wss://nostr.verymad.net +wss://relay.damus.info +wss://relays.nostrplebs.com +wss://nostr.688.org +wss://15171031.688.org +wss://dgi4mb7antpcmrx4rynm6xq52xzt5duvxa4iwucq4mszgpz6smrjajqd.onion +wss://mastodon.cloud +wss://nostr.ch3n2k.com +wss://nostr.forecastdao.com +wss://nostr.nostrelay.org +wss://nostr.robotesc.ro +wss://nostr.test.aesyc.io +wss://nostr.web3infra.xyz +wss://nostrrelay.maciejz.net +wss://nostrsxz4lbwe-nostr.functions.fnc.fr-par.scw.cloud +wss://nostrpurple.com +wss://rsslay.ch3n2k.com +wss://nos.qghs.in +wss://nostr.nordlysln.net:3241 +wss://nostr.net.in +wss://relay.rip +wss://universe.nostrich.landlangenlangja +wss://wmv-vm.local:4848 +wss://relay.austritch.net +wss://relay.oxtr.dev +wss://rwlay.bigred.social +wss://relay.lexongtonbitcoin.org +wss://knostr.neutrine +wss://nostr.online +wss://filter.stealth.wine +wss://nostream-production-5895.up.railway.app +wss://nostr.stereosteve.com +wss://relay01.apus.network +wss://test.nostr.0x50.tech +wss://nostr.0x50.tech +wss://nostr.256k1.dev +wss://nostr.malin.onl +wss://jqiwgflfw4dezjsy42frompmknrlcfazoiyngftgknj7yrmnhtobd7id.local +wss://b.ayit.org +wss://nostrelay.rajabi.ca +wss://off20chain.pub +wss://nostr.milou.land +wss://nostr.primedomain.fr +wss://nostr.theblockreward.com +wss://anon.computer +wss://relay.hamnet.io +wss://nostramsterdam.vpx.moe +wss://global-relay.cesc.trade +wss://btcpay.kukks.org +wss://relay.cent2sat.com +wss://nostr.mnethome.de +wss://nostream.sh4.red +wss://klockenga.social +wss://nostream.megadope.snowinning.com +wss://nostr.cvilleblockchain.org +wss://nostr.bitocial.xyz +wss://nostr.bitcoiner.socail +wss://nostr-relay.eniehack.net +wss://greenart7c3.dedyn.io +wss://nostr.data.naus +wss://8.tcp.ngrok.io:19607 +wss://relay.nostr24.com +wss://d463rbo7dgbfuxvvxpory2og2etl4gttfzmqcixdq7rpts47lpgolkyd.onion +wss://dublin.saoirse.dev +wss://www.weixin.com +wss://nostr-rs-relay.cryptoassetssubledger.com +wss://nostr.kojira.net +wss://nostr.fan +wss://nostr.pk +wss://getalpy.com +wss://billert.xyz +wss://circle-ay.info +wss://jawsh.xyz +wss://walletofsatoshi.com +wss://ogblock.xyz +wss://nodestrich.com +wss://kunigaku.gith +wss://nostr.21ideas.org +wss://133332.xyz +wss://asats.io +wss://nostrchack.me +wss://chalow.net +wss://cashu.me +wss://tsukemonogit.git +wss://h3y6e.com +wss://elder.nostr.land +wss://xmr.rocks +wss://nostr.build +wss://mofumemo.com +wss://kpherox.dev +wss://sb.nostr.band +wss://tyiu.xyz +wss://welkinhere.githu +wss://ocha.one +wss://in.tips +wss://vitorpamplona.co +wss://fiatjaf.com +wss://akiomik.github.io +wss://stacker.news +wss://nvk.org +wss://lordkno.ws +wss://nostr.indus +wss://lotdkno.ws +wss://nosutora.com +wss://milou.lol +wss://mostr.pu +wss://dergigi.com +wss://shirehodl.com +wss://cash.app +wss://h3z.jp +wss://murachue.cytes.net +wss://snowcait.gith +wss://jb55.com +wss://nodeless.io +wss://orange-crush.com +wss://nostr.com.au +wss://oooxxx.ml +wss://plebs.place +wss://ahr999.com +wss://penpenpng.github.io +wss://nostrpurple.co +wss://thank.eu +wss://weep.jp +wss://tigerville.no +wss://nostrcheck.me +wss://frenstr.com +wss://ln.tips +wss://ryumu.dev +wss://honeyroad.store +wss://harlembitcoin.com +wss://f7z.io +wss://relay.fan +wss://nisshiee.org +wss://www.lopp.net +wss://getalby.com +wss://heguro.com +wss://wil.bio +wss://b.tc +wss://nodedttich.com +wss://relay.taldra.in +wss://relat.nostrica.com +wss://nostr.boring.surf +wss://nostr.raitisoja.net +wss://nostr.astrox.app +wss://nostr.mjex.me +wss://slick.mjex.me +wss://nostr.hrmb.org +wss://relay.semaphore.life +wss://rss.nostr.band +wss://63ragcfwb5xhoe5gfflazfyrde3qjdo73cblhhmnbviizowdo2q5haid.onion:5051 +wss://nostrblip.app +wss://relay.vanderwarker.family +wss://relay-local.cowdle.gg +wss://relay.damus.com +wss://relay.reeve.cn +wss://relay.strfry.net +wss://relay.alxgsv.com +wss://nostrv0l.io +wss://relay.nostrula.com +wss://release.nostr.band +wss://nostr.k3tan.com +wss://nostr.bitcoin.social +wss://thesimplekid.space +wss://relay.ypcloud.com +wss://at.nostrwork.at +wss://nostream.dev.kronkltd.net +wss://nostr-relay.xbytez.io +wss://relay.nostr.io +wss://relay.nvote.co:443 +wss://relay.cryptoculture.com +wss://rjj6ejkihilniytxs56qrgtttgcfnnjvbii6vaas6jzppcmekd63ugad.local +wss://puravida.nostr.land.com +wss://bitcoinmaximalist.online +wss://wine.nostr +wss://nostr.messagepush.io +wss://nostrich.love +wss://relaynostrplebs.com +wss://hamstr.to +wss://yosupp.app +wss://snort.social +wss://relay.nostr.gt +wss://nost.ratchat.nl +wss://nostr.chrissmith.site +wss://i.relay.boats +wss://nostr.wineto +wss://nostr.eluc.ch +wss://nostrplebs.com +wss://nostr.tools.global.id +wss://nostr.rocketnode.space +wss://relay.roli.social +wss://bitcoin.nostr.com +wss://relay.badgr.space +wss://nostriches.club +wss://nostr-check.me +wss://nostrelay.nokotaro.com +wss://rbr.bio +wss://rly.bopln.com +wss://6amyhf3sjvgxe5qzbx4xn52pcnqresdmi7szxurp6umkvz6mthxjdcad.onion +wss://6amyhf3sjvgxe5qzbx4xn52pcnqresdmi7szxurp6umkvz6mthxjdcad.local +wss://20nos.lol +wss://test.theglobalpersian.com +wss://nostr.exposed +wss://nostr-pub.liujiale.me +wss://nostream.frank.snowinning.com +wss://nstrs.fly.dev +wss://eospark.com +wss://relay.nosterplebs.com +wss://nproxy.kristapsk.lv +wss://nostr.universalname.space +wss://relays.snort.social +wss://nostr.how +wss://kukks.org +wss://nostr.dutch.cryptonews +wss://relay.cryptojournaal.net +wss://relay.dutch.cryptonews +wss://nostr.cryptojournaal.net +wss://no-str.wnhefei.cn +wss://www.131.me +wss://rsr.uyky.net +wss://nostr-relay.ie9.org +wss://nostr.fmt.wis.biz +wss://nostr.exotr.dev +wss://nostr.merrcurr.com +wss://realy.damus.io +wss://nerostr.xmr.rocks +wss://rkdgwzgvcgrciemlnfsxqgyrv5whgpw44s6zycokmuchpq4ucflgjtqd.local +wss://nostr.simplex.icu +wss://ralay.damus.io +wss://relay.kronkitd.net +wss://nostr.info +wss://nostr.lnnodeinsight.com +wss://nostr.truckenbucks.com +wss://brt.io +wss://nostr.lingoh.dev +wss://relay.nor.st +wss://nostr-relay.inmarkets.com +wss://wiz.biz +wss://nostream.git +wss://nostr.dpbu.de +wss://wcl2meyp236fa3dmfzfyq6aacbdoixrlocb6zozjs6xklxizschj2did.local +wss://relay.nostr.co.jp +wss://relap.orzv.workers.dev +wss://nostr.bitcoiner.socia +wss://eden.nosrt.land +wss://strfry.cryptocartel.social +wss://nostr.citizenry.technology +wss://universe.nostrich.landlang +wss://nostr.inprivate.network +wss://relay.snort.test +wss://lpkue6jtz3pnp7zok4jwlct4n3mzuffsfrpagiffsuplyaswhdtmpoid.local +wss://nostr.rezhajulio.id +wss://lnbits.eldamar.icu +wss://nostr.freefrom.fi +wss://yael.at +wss://rain8128.github.io +wss://badges.page +wss://latam1-nostr.stealthy.co +wss://nostr.relay.se +wss://nfdn.testnet.dotalgo.io +wss://relav.nostr3.io +wss://ofchain.pub +wss://nostr.fmt.wiz.bi +wss://relay.gui.dog +wss://eden.nostry.land +wss://nostr.relay.limo +wss://relay.nostrichs.org +wss://20nostr.mom +wss://relay20damus.io +wss://nostr.sandwich.pro +wss://sq.qemura.xyz +wss://nostr-pub.seminol.dev +wss://nostr.eunundzwanzig.space +wss://nostr.bitcoinet.social +wss://nostr-relay.untether.me +wss://relay.nostr.pub +wss://nostr.100p.org +wss://7tdom3xuus7ekv423ul46w3j43zyixjj54yoe62bndpcrgii3adeppid.local +wss://nostr.gram +wss://nostr.videre.net +wss://nostr-2.zebedee.cloudwss +wss://nostr-pub.wellorder.netwss +wss://sonzai.net +wss://snort.fail +wss://ppavybjpqjft5slnpeovehbegomhwtvvxtesvwwdrfndz6qe2c5kf2ad.local +wss://nostr.land +wss://relay.nvote.com +wss://purevida.nostr.land +wss://nostr.bitcoiner.soical +wss://nostr.inosta.co +wss://nas.lol +wss://wss.nostr.milou +wss://nostr.cheesebot.org +wss://eyeswideshut.ath.cx +wss://test.relays.world +wss://relay.snort.com +wss://relay.nostrplebs.co +wss://nostr.kimi.im +wss://nostrum.com +wss://wss.nostr.wine +wss://relay.usenostr.org +wss://paladium.my:4848 +wss://umbrel.home.local:4848 +wss://nostr.metamadeenah.com +wss://lnbits.sdbtc.org +wss://relay.nostr.mutinywallet.com +wss://relay.theglobalpersian.com +wss://relay1.easymeta.app +wss://relay.nostris.online +wss://s1.wonder3.org +wss://eden.nostraland +wss://byc.klendazu.com +wss://ephemerelay.mostr.pub +wss://7si6co27cvaw5yjyx6asvxfmaw5ah2arywwgrem4y5svi5ntskoeb5id.onion +wss://nwmdev.com +wss://nostro.online +wss://nostr.massimux.com +wss://nostr.glate.ch +wss://nostr.openordex.org +wss://nostr.schorsch.fans +wss://nostr-relay-dev.nisshiee.org +wss://ibz.me +wss://alexandernostrplebs.com +wss://fishbanananostrplebs.com +wss://relay.nostrcheck.com +wss://nostr.roli.io +wss://nostr.net.za +wss://nostr.worldkey.io +wss://nostr-pub.welloorder.net +wss://nostr.actin.io +wss://nostrzebedee.cloud +wss://nostr.zclub.app +wss://nostr.13x.sh +wss://nostr.totient.xyz +wss://nostr1.federated.computer +wss://nostr.zhix.in +wss://nostr.vdstruis.com +wss://caro-relay.fiatjaf.com +wss://nostr.winewss +wss://notstro.wine +wss://nostr.btc-library.com +wss://nostr.phenomenon.space +wss://nostr.octr.dev +wss://nostr.impervious.live +wss://nostr.plebs.space +wss://iefan.tech +wss://w3ird.tech +wss://yunginter.net +wss://nostr.coach +wss://sleepy.cafe +wss://freespeechextremist.com +wss://nostr.uselessshit.com +wss://liberdon.com +wss://eveningzoo.club +wss://gleasonator.com +wss://mindly.social +wss://ottawa.place +wss://noagendasocial.com +wss://poa.st +wss://misskey.io +wss://misskey.cf +wss://nostr.bybieyang.com +wss://mastodon.online +wss://toad.social +wss://best-friends.chat +wss://universeodon.com +wss://mastodon.world +wss://mastodon.social +wss://nostr.vulpem +wss://nicecrew.digital +wss://front-end.social +wss://nostr-relay.wellorder.net +wss://relays.pro +wss://rsslay.sovbit.host +wss://seal.cafe +wss://social.6bq.de +wss://universe.nostrich.landlangjalangzh +wss://social.xenofem.me +wss://mi.hibi-tsumo.com +wss://mstdn.social +wss://mas.to +wss://relay.rebelbase.site +wss://pixelfed.social +wss://digitalcourage.social +wss://ruby.social +wss://cr8r.gg +wss://nijimiss.moe +wss://returtle.com +wss://lor.sh +wss://toot.community +wss://mstdn.jp +wss://relay.berserker.town +wss://nostr.rajabi.ca +wss://fosstodon.org +wss://misskey.takehi.to +wss://izj3isbk3pmade74ontdijodhehsytnw2iokdhh6k3flk4mq2pau6sid.onion +wss://mastodon.scot +wss://nostriches.org +wss://aus.social +wss://relay.nostr.amane.moe +wss://romancelandia.club +wss://stonez.me +wss://merrcurr.com +wss://nostr.ist +wss://onprem.wtf +wss://fedibird.com +wss://s2.wonder3.org +wss://freespech.casa +wss://disabled.social +wss://relay.house +wss://nostr-pub.welllorder.net +wss://social.teamb.space +wss://infosec.exchange +wss://blockedur.mom +wss://progressivecafe.social +wss://mstdn.ca +wss://c.im +wss://mefi.social +wss://basebitcoinplebs.place +wss://med-mastodon.com +wss://ohai.social +wss://defcon.social +wss://pxlmo.com +wss://zlocur7ctbds4qsdswb3qpkp6n2e2ywqne2fp2tdn4fqubpudfiyxwid.local +wss://thebag.social +wss://mstdn.party +wss://relay.serpae.xyz +wss://clew.lol +wss://qoto.org +wss://relay.exchange +wss://nostr.relayable.org +wss://mastodon.coffee +wss://kmy.blue +wss://home.social +wss://detmi.social +wss://brighteon.social +wss://nostr.nom +wss://theblower.au +wss://astral.nostr.land +wss://beige.party +wss://orangepill.dev +wss://redgreenblue.click +wss://nostr.fredix.xyz +wss://nostr.essydns.ca +wss://nostr.private.network +wss://nostr.member.cas +wss://nostr-rely.digitalmob.ro +wss://relay.nostr +wss://relay.current +wss://no-str.or +wss://pl.gamers.exposed +wss://studentchadpolytechnic.com +wss://scicomm.xyz +wss://relay.alien-sos.gov +wss://masto.es +wss://spinster.xyz +wss://parallels-parallels-virtual-platform.local:4848 +wss://relay.daums.io +wss://kolektiva.social +wss://mastodonapp.uk +wss://convo.casa +wss://sfba.social +wss://techhub.social +wss://leafposter.club +wss://nrw.social +wss://mastodon.uno +wss://handon.club +wss://social.vivaldi.net +wss://nostr.goller.net +wss://minazukey.uk +wss://mstdn.beer +wss://expressional.social +wss://paid.nostr.0x50.tech +wss://mstdn.nere9.help +wss://relay.nostr.ai +wss://relay.noswss +wss://relay.nwss +wss://furry.engineer +wss://uselessshit.co +wss://kosmos.social +wss://mynostr.io +wss://premis.one +wss://social.tchncs.de +wss://retro.pizza +wss://pearl-mount-showed-fishing.trycloudflare.com +wss://kpa4k6acxzjv2m2p72keftbpaymwpq2h67jqnin3d4y3djxyheuifoqd.onion +wss://gm7.social +wss://relays.nostr.info +wss://chitter.xyz +wss://hackers.town +wss://anarchism.space +wss://relay.nostrica +wss://halifaxsocial.ca +wss://asimon.org +wss://nostr.blipme.add +wss://troet.cafe +wss://octodon.social +wss://m.cmx.im +wss://filename-ambassador-distance-mountains.trycloudflare.com +wss://nostr.getgle.org +wss://relay.froth.zone +wss://novoa.nagoya +wss://relay.dispute.systems +wss://clubcyberia.co +wss://thechimp.zone +wss://coolsite.win +wss://puravida.nostra.land +wss://shelter.local:1111 +wss://det.social +wss://nostr-2.zebee.cloud +wss://chaosfem.tw +wss://nostr.v01.io +wss://social.kechpaja.com +wss://mastodon.green +wss://plebchain.nostr +wss://plebchain.nostr.land +wss://relay.onsats.org +wss://u5epuanp2fbie4phw6zekzna6zotvsffji4td4ee7iwgwdxlz4kwqqad.onion:5051 +wss://3ddc8bbee6db.ngrok.app +wss://b4968f09859e.ngrok.io +wss://genserver.social +wss://masto.ai +wss://hachyderm.io +wss://shitpost.cloud +wss://oldbytes.space +wss://mastodon.ie +wss://baraag.net +wss://nostr.dvdt.dev +wss://relay.com.de +wss://nostr.rbel.co +wss://bologna.one +wss://nostr-relay.app +wss://kagamisskey.com +wss://nostr-rs-relay.phamthanh.me +wss://nostr.bcmp.com +wss://blogstack.io +wss://rly.nostrkid.com +wss://mastodon.bida.im +wss://freeatlantis.com +wss://pieville.net +wss://climatejustice.rocks +wss://relay.mynostr.id +wss://relay.farscapian +wss://relay.blogstack.io +wss://orwell.fun +wss://misskey.04.si +wss://sushi.ski +wss://nostr.milou.lo +wss://pawoo.net +wss://tooter.social +wss://mastodon.sdf.org +wss://nostr.com +wss://misskey.design +wss://macaw.social +wss://relay.nostr.inforelay.nostr.band +wss://pub1.southflorida.ninja +wss://strawberry-pudding.net +wss://mastodon-japan.net +wss://nostream.0x50.tech +wss://multiplextr.coracle.social +wss://pleroma.skyshanty.xyz +wss://uxxq6b2enojvflhkrzsg4erakd5rrb7v2cql4m4pspj4xtqwyli47rid.local +wss://masto.deoan.org +wss://replay.damus.io +wss://melhorque.com.br +wss://nostr1.actn.io +wss://nostr.bolt.fun +wss://relay.vtbmoyu.com +wss://eosla.comrelay.zeh.app +wss://bikeshed.party +wss://nostr.xanny.family +wss://milker.cafe +wss://nostr.give.africa +wss://relay.got-relayed.com +wss://cum.salon +wss://puravid.nostr.land +wss://soc.punktrash.club +wss://seafoam.space +wss://h4.io +wss://nostr.swiss.enigma.ch +wss://toot.cafe +wss://sneed.social +wss://newsie.social +wss://indg.club +wss://xoxo.zone +wss://relay.nostr.bitcoiner.social +wss://relay.snort.band +wss://socel.net +wss://social.coop +wss://postpandemicparty.org +wss://merveilles.town +wss://search.nostr.wine +wss://search.nos.today +wss://mstdn-huahin.com +wss://eden.nostr.la +wss://nostrja-kari-nip50.heguro.com +wss://relay.gems.xyz +wss://plnetwork.xyz +wss://nostr.swiss-enigma.sh +wss://geofront.rocks +wss://toot.io +wss://indieweb.social +wss://mastodon.content.town +wss://awaymessage.club +wss://relay.intify.io +wss://mstdn.science +wss://maniakey.com +wss://otadon.com +wss://mstdn.guru +wss://misskey.noellabo.jp +wss://hessen.social +wss://mastodon.top +wss://ipv6.nostr.wirednet.jp +wss://relay.nostr-relay.org +wss://cum.camp +wss://nebbia.fail +wss://current.fyi +wss://bae.st +wss://lolison.network +wss://ioc.exchange +wss://bylines.social +wss://decayable.ink +wss://nostr.unitedserializer.com +wss://urbanists.social +wss://dark-elves.social +wss://writing.exchange +wss://nostr.rikmeijer.nl +wss://misskey.social +wss://ht.nixre.net +wss://mathstodon.xyz +wss://t7jvqwu35hneszx7fihsprbcpwonlcfnsjr4xtn6shqgwbv324w4gdid.local +wss://abla.news +wss://muenchen.social +wss://homeserver.drake-carp.ts.net:4848 +wss://snailedit.social +wss://mastodon.gamedev.place +wss://tech.lgbt +wss://mast.lat +wss://econtwitter.net +wss://veganism.social +wss://btclolap6mm4tl37huslk6j76enq7qxaj2kwq7w6cdr5ros56eletcqd.onion +wss://toot.cat +wss://nostr.zenon.info +wss://misskey.art +wss://nostr.hushvault.ie:4848 +wss://o6ga6lxnax2z7pgkkenifollohkrv55r36mdzdtofi7d5yyif2f4o5yd.onion:5051 +wss://20nostr-pub.wellorder.net +wss://ecoevo.social +wss://nostr.zerofiat.world +wss://nostr.atitlan.io +wss://detroitriotcity.com +wss://rollenspiel.social +wss://paquita.masto.host +wss://literatur.social +wss://dave.st.germa.in +wss://sunny.garden +wss://nostrpro.xyz +wss://relay.getalby.com +wss://misskey.systems +wss://mamot.fr +wss://social.anoxinon.de +wss://relay.nostr.social +wss://mindmachine.org +wss://microblog.club +wss://relay.utxo.com +wss://universe.nostrich.landlangth +wss://social.teci.world +wss://social.freetalklive.com +wss://mk.absturztau.be +wss://social.ornella.xyz +wss://left-tusk.com +wss://bofh.social +wss://a11y.social +wss://shroomslab.net +wss://relay2.nostr.vet +wss://mugicha.club +wss://laserbeak.local:4848 +wss://annihilation.social +wss://humble.cafe +wss://nostr.relay.damus.io +wss://nostr.milol.lol +wss://nostr.blimpme.app +wss://noc.social +wss://wetdry.world +wss://nostr.taxi +wss://nostr.21-bitcoin.org +wss://relay.llevotu-bitcoiners.info +wss://pl.kitsunemimi.club +wss://djsumdog.com +wss://citadel.local:4848 +wss://federate.blogpocket.com +wss://chaos.social +wss://mastodon.me.uk +wss://oxtr.dev +wss://misskey.cloud +wss://varishangout.net +wss://lacosanostr.com +wss://willem.currycash.net:4848 +wss://lightniningrelay.com +wss://queer.party +wss://lightning.relay.com +wss://mastodon.nl +wss://purplepag.es +wss://cupoftea.social +wss://bitcoiner.socialwss +wss://relay.current.io +wss://sigmoid.social +wss://wandering.shop +wss://braydmedia.de +wss://quey.la +wss://nostr.zebeedee.cloud +wss://nostr-2.zebeedee.cloud +wss://artsio.com +wss://nostr1676031941328.app.runonflux.io +wss://arsip.ddns.net +wss://relay.nostrcitadel.org +wss://relay.nostr-citadel.org +wss://nostr-citadel.org +wss://blastr.f7z.io +wss://nostr.myowndamnnode.com +wss://snowdin.town +wss://nostr.zxcvbn.space +wss://relay.notmandatory.org +wss://bitcoin.social +wss://friendsofdesoto.social +wss://zirk.us +wss://digipres.club +wss://nostr-test.elastos.io +wss://nostr.onsat.org +wss://damus.relay.io +wss://beefyboys.win +wss://nostrija-kari.heguro.com +wss://froth.zone +wss://ns.penseer.com +wss://relay.nostrical.com +wss://nostr.hoshizora.ch +wss://kunigaku.github.io +wss://nostr.shino3.net +wss://umbrel.tailbb128.ts.net:4848 +wss://tailbb128.ts.net:4848 +wss://fedi.twoshortplanks.com +wss://filter.nostr.winebroadcasttrue +wss://nostr.doufu-tech.com +wss://relay.hodl.haus +wss://pgh.social +wss://coeditor-congested.fractalnetworks.co +wss://mastodon.podaboutli.st +wss://frenfiverse.net +wss://nostr.f4255529.fun +wss://nostream.megadope.snowinning +wss://kiritan.work +wss://forever21.lol +wss://zitron.net +wss://mastodon-swiss.org +wss://relay1.current.fyi +wss://nostr.plebs.com +wss://multiplextr.corocal.social +wss://nostr.zedebee.cloud +wss://nostr.mutinywallet.comisntbanned.addthatonesotheycangetnotesrelayedtotherestofthenetworkfrominsideofchina +wss://mastodon.mit.edu +wss://relaynostrati.com +wss://relaynostr.band +wss://relaynostr.info +wss://relaychenxixian.cn +wss://relaysnort.social +wss://relaynostr.com.au +wss://nostr-tbd.website +wss://relay.hoshizora.ch +wss://social.balsillie.net +wss://strangeobject.space +wss://relay.nvote20.co +wss://fla.red +wss://urusai.social +wss://chad.polytechnic.com +wss://robo358.com +wss://conxole.io +wss://relay.ohbe.me +wss://nostr.bitcoiner.com +wss://www.mutinywallet.com +wss://x.9600.link:10070 +wss://a11y.info +wss://homelab.host +wss://k65qz57zx4sw24fow2bvchjgmpjqljj4cp3oa7dtpbbprjifsdotggid.local +wss://relay.vtuber.directory +wss://x.9600.link:8000 +wss://nostr.plebs.win +wss://renkontu.com +wss://nost.massmux.com +wss://submarin.online +wss://social.librem.one +wss://relai.kongerik.et +wss://mstdn.io +wss://astral.swiss-enigma.ch +wss://stereophonic.space +wss://relayer.pleb.social +wss://mastodon.nz +wss://nostr.nostr.de +wss://nostr.thezap.club +wss://proxy.shroomslab.net +wss://pfr24mrpxowclhm4y6adu36kbo3erx7gskzyfqqhnhfpdkdmylpfgkyd.local +wss://nostr.hodl.haus +wss://vtdon.com +wss://relay.nost.band +wss://fnxwipsg3lfzij64lvjgmutvkkpd7eo2mr2khxkofyywf3vsvbk73jad.onion +wss://fnxwipsg3lfzij64lvjgmutvkkpd7eo2mr2khxkofyywf3vsvbk73jad.local +wss://md.hugo.nostr +wss://boks.moe +wss://truthsocial.co.in +wss://mastodon.xyz +wss://relay.universalname.space +wss://relay-nostr.wirednet.jp +wss://fedi.pawlicker.com +wss://favcalc.com +wss://rot13maxi.com +wss://awayuki.net +wss://mazinkhoury.com +wss://g0v.social +wss://shpposter.club +wss://a.lufimianet.jp +wss://pura20vida.nostr.land +wss://sotalive.net +wss://sendsats.lol +wss://vida.page +wss://wagvwfrdrikrqzp7h3b5lwl6btyuttu7mqpeji35ljzq36ovzgjhsfqd.onion +wss://uec2cmjauzufrtlq6wq6l2ujfncdvo3suezz423gsvz5xvhehm2mcgid.onion +wss://mastodon-belgium.be +wss://rejecttheframe.xyz +wss://nogood.store +wss://getaiby.com +wss://plebchain.club +wss://nostrverified.com +wss://x.9600.link +wss://n0p0.shroomslab.net +wss://orangemakura.xyz +wss://jamw.net +wss://7ab7qqbj2dw3pjnkoskgsfn4ikqc7orwnkpmcfjmeobw63kf4zgykjid.local +wss://snabelen.no +wss://floss.social +wss://mastoot.fr +wss://kmc-nostr.amiunderwater.com +wss://hodl.camp +wss://xmr.usenostr.com +wss://nostr-pub.wellorn.net +wss://gudako.net +wss://ryona.agency +wss://kafeneio.social +wss://massmux.com +wss://freezepeach.online +wss://nostream-production-f83d.up.railway.app +wss://taobox.pub +wss://mastodon.radio +wss://einundzwanzig.relay.com +wss://journa.host +wss://social.here.blue +wss://toot.wales +wss://staging.nostr.com.se +wss://pleroma.soykaf.com +wss://berserker.town +wss://zapforart.site +wss://forall.social +wss://nostrja-kari.heguro.comyee +wss://higheredweb.social +wss://social.gnuhacker.org +wss://the.hodl.haus +wss://ng4jk6yiqgfczo4wyxszuj7w6jok3fptehu533o3mlzs3vph3dvjfdid.onion +wss://nostr.mtpx.ovh +wss://relay.coollamer.com +wss://glasgow.social +wss://fedi.absturztau.be +wss://nostr.frostr.xyz +wss://relay.runningnostr.lol +wss://relay2.vtuber.directory +wss://nerdculture.de +wss://nostr-relay.untethr.meoperator +wss://nostr.verif-slothy.win +wss://nostr.pub +wss://social.process-one.net +wss://invillage-outvillage.com +wss://otofu.uk +wss://universe.nostrich.landlangenlangpt +wss://mastodon.iriseden.eu +wss://frighteningdeafeningagent.nailuogg.repl.co +wss://obo.sh +wss://relay.hodlhaus.net +wss://rdrama.cc +wss://nostr.packetlostandfound.us +wss://test.relay +wss://mastodonbooks.net +wss://southflorida.ninja +wss://blob.cat +wss://tooting.ch +wss://relay.pineapple.pizza +wss://relay.nostr.directory +wss://relar.nostr.bg +wss://reisen.church +wss://relay.honk.pub +wss://nostr.rsfriedl.com +wss://relay.nort.social +wss://1611.social +wss://the.hodl.house +wss://relayable.com +wss://fedi.syspxl.xyz +wss://nostrpub.yeghro.site +wss://skr5bbrgzfnideglw4cs2iw6au2jm2b7gupocxbmkv5qopo6rcitmiqd.local +wss://a2mi.social +wss://status.relayable.org +wss://toot.blue +wss://btcqspp5dl4rlgl5pomcyv3odfeki7a5zrmjoekyu5vsoqz5bth4e7yd.onion +wss://bitcoinr6de5lkvx4tpwdmzrdfdpla5sya2afwpcabjup2xpi5dulbad.onion +wss://7tdom3xuus7ekv423ul46w3j43zyixjj54yoe62bndpcrgii3adeppid.onion +wss://dnze4ekho2kuiejwatjw5omeprtmdaum2ukok52roiu5rztii3rp2aid.onion +wss://53snncs7vegargpaardbxjnii2oan3xpmbeaf6czwoqa2axz5mvbsjid.onion +wss://fnqdhz3df33da6wxg7jskvumd5rjn3nknln6ecun7uwwysc7vkwkjgid.onion +wss://relay.thefockinfury.wtf +wss://silliness.observer +wss://freak.university +wss://piaille.fr +wss://nostr.weking.tk +wss://f.reun.de +wss://radixrat.com +wss://hostux.social +wss://chat.freenode.net +wss://no.str..cr +wss://nostr.inoata.cc +wss://kappa.seijin.jp +wss://floyds.io +wss://mast.dragon-fly.club +wss://zbd.ai +wss://misc.name +wss://pdx.social +wss://0w0.is +wss://d6jvu2tev2rblkuzgu4ydw2413jizr53j26ut47hxpykvtusbvekhiid.onion +wss://d6jvu2tcv2rblkuzgu4ydw24l3jizr53j26ut47hxpykvtusbvekhiid.onion +wss://drcassone.social +wss://mastodon.energy +wss://rayci.st +wss://ischool.social +wss://nostr.halfway2forever.com +wss://relay.nostr.rocks +wss://nostr.stoner.com +wss://2nodez.com +wss://rs.nostr-x.com +wss://schleuss.online +wss://thrashzone.org +wss://relay.nostrgraph.com +wss://relay.2nodez.com +wss://occult-zuki.com +wss://mastodon.lithium03.info +wss://bigbadpc.local:4848 +wss://social.gr0k.net +wss://nostr.wirednet.jp +wss://relay.plebster.com +wss://bitcoin.nostr +wss://mastodon.im +wss://brb..io +wss://nostr.sandwhich.farm +wss://relay.nos.lol +wss://beta.nostr.v0l.io +wss://eden.nostr.space +wss://powerlay.xyz +wss://rap.social +wss://relay.mutinywallet.com +wss://rogue.earth +wss://600.wtf +wss://klabo.blog +wss://petrikajander.com +wss://tgkzmdd.help +wss://nostr.red +wss://brb.lol +wss://jz2l2bf6f6wssdqwkg7ogthkc5i3ymyiwkaz3tbhff6ro3h3zqddekyd.onion +wss://mstdn.mini4wd-engineer.com +wss://nostr.exposd +wss://nostr.a-ef.org +wss://computerfairi.es +wss://social.targaryen.house +wss://o3o.ca +wss://walkah.social +wss://cosocial.ca +wss://filter.nostr.band +wss://relais.nostrview.com +wss://gigaohm.bio +wss://dobbs.town +wss://bark.lgbt +wss://mastodon.gal +wss://snug.moe +wss://genomic.social +wss://relay.orangepilldev.com +wss://social.sdf.org +wss://social.camph.net +wss://mstdn.poyo.me +wss://nein.lol +wss://nostr.i00.org +wss://kemono.ink +wss://mu.zaitcev.nu +wss://libera.site +wss://ca.hibi-tsumo.com +wss://social.bund.de +wss://xscape.top +wss://social.lol +wss://birds.town +wss://arnostr.com +wss://nostr.33co.de +wss://relay.nostr.lighting +wss://metadata-contacts-relays.pages.dev +wss://webs.node9.org +wss://pleroma.elementality.org +wss://suya.place +wss://livellosegreto.it +wss://peoplemaking.games +wss://nattois.life +wss://typo.social +wss://neutrine.com +wss://ragner-relay.com +wss://wss.nostr.uselessshit.co +wss://wss.nostrue.com +wss://relay.nvote.co:433 +wss://disobey.net +wss://rneetup.com +wss://arnostr.com:8433 +wss://relay.uxto.one +wss://relay.hackerman.pro +wss://thisis.mylegendary.quest +wss://poliversity.it +wss://sats.lnaddy.com +wss://rs2.abaiba.top +wss://rs1.abaiba.top +wss://rs2.abaiba.top.abaiba.top +wss://social.matarillo.com +wss://nostr01.counterclockwise.io +wss://backup.local:4848 +wss://touhou.vodka +wss://mi-wo.site +wss://nostr.f7z.io +wss://alive.bar +wss://strfry.nostr-x.com +wss://mastodontti.fi +wss://nostr.wellorder.net +wss://y.9600.link:8000 +wss://ephemrelay.mostr.pub +wss://byc-italia.online +wss://fissionator.com +wss://stranger.social +wss://eupolicy.social +wss://nostr-desktop.local:4848 +wss://aoir.social +wss://mstdn.plus +wss://nostrproxy.io:3333 +wss://mastodon.hams.social +wss://jorts.horse +wss://metalhead.club +wss://dice.camp +wss://mstdn.y-zu.org +wss://loffchain.pub +wss://mastodon.llarian.net +wss://nostr-relay2.thefockinfury.wtf +wss://2g2jzcfgq5lcrceuq23lmya2drm3ku5qmqimr3bvu3amol55vidctrad.onion +wss://mastodon.au +wss://bgme.me +wss://nostr.badran.xyz +wss://nostr.coincreek.com +wss://nostream-test.up.railway.app +wss://relay.blackthunder.click +wss://relay.grorp.com +wss://atomicpoet.org +wss://iddqd.social +wss://gusto.masto.host +wss://lifehack.social +wss://blorbo.social +wss://freecumextremist.com +wss://13bells.com +wss://rsslay.nostr.netrelay +wss://relay.zerosequioso.com +wss://nauka-relay.herokuapp.com +wss://nostr.nofdeofsven.com +wss://out.of.milk +wss://cawfee.club +wss://r.relay.fan +wss://alo.ottonove891.cf +wss://keinoha.tailnet-0240.ts.net +wss://nostr.paralelnipolis.cz +wss://tuiter.rocks +wss://elizur.me +wss://nostr-dev.newstr.io +wss://discuss.systems +wss://blahaj.zone +wss://mastodon.art +wss://makersocial.online +wss://gamepad.club +wss://nostr.flameofsoul.ru +wss://dmv.community +wss://relay.nostr.com +wss://nostr-desktop.saiga-shark.ts.net:4848 +wss://nostrfoxden.ddns.net:4848 +wss://soc.umrath.net +wss://ravenation.club +wss://oisaur.com +wss://nostr-relay.net +wss://social.mikutter.hachune.net +wss://lewacki.space +wss://fediscience.org +wss://todon.eu +wss://nuccy-nuc7i5bnk.local:4848 +wss://games.gamertron.net:4848 +wss://fiedlerfamily.net +wss://postnstuffds.lol +wss://nostr.planetary.social +wss://worldkey.io +wss://hcommons.social +wss://gymp7qquljs47xbbvs47hkptnyyzegy2jkst26mkjxaciifqffjatqid.onion +wss://rs3.abaiba.top +wss://sudo-nostr.com +wss://satgag.site +wss://nostr.lnbitcoin.cz +wss://relay20nostrplebs.com +wss://2pbkpndvpeebljfvjew6auq63lndzszqnntct5aqfmazslerzxe75kad.onion +wss://dragonchat.org +wss://welcome.nostr.wine +wss://relay.nostr.land +wss://social.linux.pizza +wss://dnppj4kopczovvzvpzmihv2iwe5wt3gbrxjnltjc2zdjpttrdz4owpad.onion +wss://potofu.me +wss://nostrbr.online +wss://mastorol.es +wss://notebook.taild34d0.ts.net +wss://t.aqn.jp +wss://nostr.mutinywallet +wss://wcone.nostr.wine +wss://norden.social +wss://eostagram.com +wss://shigusegubu.club +wss://toot.jkiviluoto.fi +wss://kiwifarms.cc +wss://swiss-talk.net +wss://v532btfg2fb4za2g476a7w23pgpkllc7uq274wqtktwjogt5ynb3ukqd.local +wss://mstdn.maud.io +wss://arc1.arcadelabs.com +wss://nostr.jp +wss://relay-jp.nostr.wirrdnet.jp +wss://climatejustice.social +wss://witter.cz +wss://mastodon.pnpde.social +wss://ttrpg-hangout.social +wss://beehaw.org +wss://thecanadian.social +wss://nostr.fbxl.net +wss://relay.sandwich.farm +wss://nostr.olwe.link +wss://botsin.space +wss://zeroes.ca +wss://photog.social +wss://paid.nostr.lc +wss://free.nostr.lc +wss://test.nostr.lc +wss://gzanlkgurj7zd3psqms3da4vrw4imurnyyzaycfuiiug7elqow7xlayd.onion:5051 +wss://masto.nu +wss://mastodon.uy +wss://bit.relay.center +wss://offchain.relay.center +wss://damus.relay.center +wss://wine.relay.center +wss://eden.relay.center +wss://moth.social +wss://nostr.masmux.com +wss://chrome.pl +wss://mastodon.ktachibana.party +wss://ak.kawen.space +wss://mementomori.social +wss://relay.s3x.social +wss://lnbits.michaelantonfischer.com +wss://yof23ggqmert72c5wcl5qglphapy3o2xjdedtkbrn2dt5rbae2s7f6qd.onion +wss://relay.snort.socail +wss://post.lurk.org +wss://yiff.life +wss://q3zaylwjjhq77yzx34lbydz26szzjljberwetkjgxgsapcekrpjzsmqd.onion +wss://lnbits.b1tco1n.org +wss://welcome.nostr.relay +wss://sound-money-relay.denizenid.com +wss://carnivore-diet-relay.denizenid.com +wss://africa.nostr.joburg +wss://nostr.jolt.run +wss://nostr.chainbits.co.uk +wss://ithurtswhenip.ee +wss://nostr.cloudversia.com +wss://relay1.east.us.nostr.btron.io +wss://ca.orangepill.dev +wss://pdx.land +wss://linh.social +wss://okla.social +wss://androiddev.social +wss://spore.social +wss://mastodo.fi +wss://kabedon.space +wss://nost.inosta.cc +wss://relay2cdamus.io +wss://nostr.openhoofd.nl +wss://dragonscave.space +wss://genart.social +wss://dewp.space +wss://layer8.space +wss://qou7zzll2mxx2ehl73n6pptmhizl5b3entowljlin3sqhcvltxdtlmad.onion:5051 +wss://nostr.wines +wss://relay.snort.relay.ryzizub.com +wss://pixelfed.de +wss://nostr.holyscapegoat.com +wss://nostr.einunzwanzig.space +wss://nostr.hifish.org +wss://colearn.social +wss://topspicy.social +wss://mastodon.neat.computer +wss://relay.nostr.hach.re +wss://nostr.dakukitsune.ca +wss://7ab7qqbj2dw3pjnkoskgsfn4ikqc7orwnkpmcfjmeobw63kf4zgykjid.onion +wss://esq.social +wss://famichiki.jp +wss://tribe.net +wss://masto.nobigtech.es +wss://umbrel-nuc.local:4848 +wss://mastodon.cocoasamurai.social +wss://debian.taildd32b.ts.net:4848 +wss://vlt.ge +wss://relay.johnnyasantos.com +wss://snort.relay.center +wss://nb.relay.center +wss://waag.social +wss://concentrical.com +wss://stat.rocks +wss://oransns.com +wss://relay-jpp.nostr.wirednet.jp +wss://oc.todon.fr +wss://jundow.gitlab.io +wss://neurodifferent.me +wss://jazztodon.com +wss://nostrich.friendship +wss://indieauthors.social +wss://werunbtc.com +wss://frogtalk.lol +wss://pop-os.local:4848 +wss://fediver.de +wss://d6qxo55dhms6revgrmbindvb5ejd3gw5hrji7ylkm6khghii3hjs3uyd.onion +wss://eldritch.cafe +wss://karlsruhe-social.de +wss://social.yl.ms +wss://nostr.mycloudhouse.duckdns.org +wss://nostr.otc.sh +wss://nya.social +wss://relay2.nostrchat.io +wss://relay1.nostrchat.io +wss://nostrja-world-relays-test.heguro.com +wss://ndk-relay.local +wss://reespeech.casa +wss://pleroma.atyh.cc +wss://lawfedi.blue +wss://akkoma.jasminetea.uk +wss://peeledoffmy.skin +wss://plush.city +wss://astrodon.social +wss://samenet.social +wss://toot.bike +wss://mi.yukioke.com +wss://social.growyourown.services +wss://mastodon.nu +wss://lou.lt +wss://functional.cafe +wss://relaydamus.io +wss://coma.social +wss://social.fbxl.net +wss://biplus.social +wss://toots.matapacos.dog +wss://psychoet.ml:3250 +wss://danserver.equipment +wss://nostr.lacrypta.com.ar +wss://wonkodon.com +wss://nostr.seankibler.com +wss://autistics.life +wss://cambrian.social +wss://rvqkqr5kl3dvvxyn67rfowcnvoflx4zby5tjbysavym4ycckti4dbjyd.onion +wss://swiss.nostr.lc +wss://snac.saifulh.online +wss://loma.ml +wss://nostr.privoxy.io +wss://366.koyomi.online +wss://mastodon.stormy178.com +wss://podcastindex.social +wss://bitcoiner.socia +wss://fedi.ml +wss://replayable.org +wss://nostr.schroomslab.net +wss://nostr.global.fans +wss://relay.weedstr.net +wss://vocalodon.net +wss://relay.nostr.bandadd +wss://nostr.oxtr.devadd +wss://jarvis.taild68e2.ts.net:4848 +wss://t7jvqwu35hneszx7fihsprbcpwonlcfnsjr4xtn6shqgwbv324w4gdid.onion +wss://stonez.local:4848 +wss://notrustverify.ch +wss://woof.group +wss://mastodon.floe.earth +wss://lnbits.plebtag.com +wss://kpop.social +wss://relay.wavlake.com +wss://mastodon.sharma.io +wss://travelpandas.fr +wss://alphapanda.prowss +wss://relay.saes.io +wss://barelysocial.org +wss://masto.komintern.work +wss://norcal.social +wss://nostr.zbd.gg +wss://mk.outv.im +wss://mstdn.mx +wss://col.social +wss://nostr.freedom.fi +wss://filter.nostr.winebroadcasttrueglobalall +wss://eden.nostr.landv +wss://me.dm +wss://emacs.ch +wss://winonostr.wine +wss://infoplebstr.com +wss://mas.towss +wss://gruene.social +wss://relay.freeplace.nl +wss://itis.to +wss://bsky.social +wss://lnbits.thefockinfury.wtf +wss://5xxkt7zvmh4zdsjw64lgvchjdlrrgw4w2huujiiud35qms6gnkn5azad.onion +wss://eientei.org +wss://artisan.chat +wss://nustr.mom +wss://relay.nostrhraph.net +wss://shitposter.club +wss://nostril.cam +wss://nostr.spaceshell.xyz +wss://relay.wtr.app +wss://tdd.social +wss://d3meec25b53kegrnjmtmtyynikbkmuxf4jqgtk3sonjs6e62hpaezyqd.onion +wss://tkz.one +wss://freerelay.xyz +wss://nfdn.betanet.dotalgo.io +wss://mitra.social +wss://hablanews.io +wss://calle.wtf +wss://voskey.icalo.net +wss://nostr.sloyhy.win +wss://framapiaf.org +wss://nostrnodeofsven.com +wss://ciberlandia.pt +wss://gnusocial.net +wss://relay.s3x.socia +wss://paste.2nodez.com +wss://union.place +wss://bofh.socia +wss://sovbit.dev +wss://lnbits.btc-payserver.eu +wss://osna.social +wss://im-in.space +wss://junxingwang.org +wss://relay.nostr.wirednet.jpcheck +wss://libranet.de +wss://fedisnap.com +wss://woodpecker.social +wss://gensokyo.town +wss://social.imirhil.fr +wss://links.potsda.mn +wss://law-and-politics.online +wss://nostrich.bar +wss://tweesecake.social +wss://nostr.kisiel.net.pl +wss://relay.kisiel.net.pl +wss://calckey.social +wss://nostr.cercatrowa.me +wss://meganekeesu.tokyo +wss://lndiscs.duckdns.org +wss://omochi.xyz +wss://wue.social +wss://nostr.libreleaf.com +wss://rusnak.io +wss://rsslay-production.up.railway.app +wss://nostr.yuhr.org +wss://bod4ojj37fneith2setv3qjbii563wesbjqgdipdz4ag6voic2xk5iad.onion +wss://fediverse.blog +wss://pouet.chapril.org +wss://baq5ufl2rnczpalnoqabxwpjm3kvhzduwgvptxzx7yq37oqdbgf65syd.local +wss://baq5ufl2rnczpalnoqabxwpjm3kvhzduwgvptxzx7yq37oqdbgf65syd.onion +wss://cryptodon.lol +wss://nostr.debancariser.com +wss://mizunashi.hostdon.ne.jp +wss://relay.deezy.io +wss://bbq.snoot.com +wss://historians.social +wss://mi.mashiro.site +wss://mastodonpost.social +wss://nostrpub.welliorder.net +wss://social.cologne +wss://metapixl.com +wss://wandzeitung.xyz +wss://lightninhrelay.com +wss://techopolis.social +wss://lnbits.btcpins.com +wss://purplenostrich.com +wss://onewilshire.la +wss://sself.co +wss://anygemini13.blogs.sapo.pt +wss://federated.press +wss://metadata.nostr.com +wss://nostr.hodl.ar +wss://goreslut.xyz +wss://mastodon.com.tr +wss://climatejustice.global +wss://brotka.st +wss://sueden.social +wss://mstdn.fr +wss://abid.cc +wss://lnbits.fuckedbitcoin.com +wss://meow.social +wss://nostr.rehab +wss://mstdn.media +wss://nodeo1.nostress.cc +wss://nostrmassmux.com +wss://sauropods.win +wss://civilians.social +wss://pnw.zone +wss://zebeedee.cloud +wss://nostr.walletofsatishi.com +wss://aufovmqaxj5nhqmtorhgpogdjxefhkff25cbyyjt2sub3vwg6b6rplid.onion +wss://forfuture.social +wss://76f67qcwxsxpz7cfozlzunota2ejqznpldc5pnqtyq233hjpjzrmlfid.local +wss://toot.garden +wss://umbrel.tail9dfb.ts.net:4848 +wss://mastodon.chasem.dev +wss://misskey.pm +wss://merovingian.club +wss://chirp.enworld.org +wss://paid.no.str.ce +wss://masto.bike +wss://masto.1146.nohost.me +wss://eosla.comno-str.orgrelay.zeh.appno-str.orgrelay.zeh.app +wss://nixnet.social +wss://pay.zapit.live +wss://respublicae.eu +wss://nekomiya.net +wss://mastodon.internet-czas-dzialac.pl +wss://plushies.social +wss://lnb.openchain.fr +wss://mastodon.lol +wss://social.rebellion.global +wss://ruhr.social +wss://mi.farland.world +wss://pkutalk.com +wss://systemli.social +wss://nostr.minimue81.selfhost.com +wss://mastodon.la +wss://everything.happens.horse +wss://pagan.plus +wss://clacks.link +wss://u-tokyo.social +wss://fediverse.projectftm.com +wss://mastodon.bachgau.social +wss://social.kabi.tk +wss://ty3zdjkwlxo4zah6tgdoolznjcbvkhxpcvjyqe2buxeg23hbeyvr3rad.local +wss://pleroma.wakuwakup.net +wss://social.horrorhub.club +wss://nostr.nightowlstudios.ca +wss://mastodon.ml +wss://relay.orangepillapp.com +wss://misskey.sup39.dev +wss://mfmf.club +wss://pokemon.mastportal.info +wss://gohan-oisii.net +wss://aipi.social +wss://nostr.semisol.com +wss://oslo.town +wss://relay.layer.systems +wss://naharia.net +wss://social.elbespace.de +wss://linuxrocks.online +wss://b81m3pf94ridtry53g8ufyyrjtjaoxgbyjbs5k8qrqkr1whocxiy.loki:8080 +wss://lvl01.tater.ninja +wss://kinky.business +wss://relay.bitblockboom.com +wss://fedi.omada.cafe +wss://social.secret-wg.org +wss://celebrity.social +wss://weirdo.network +wss://mastodon.design +wss://berlin.social +wss://misskey.yukineko.me +wss://mindmachine.688.org +wss://sackheads.social +wss://a.farook.org +wss://social.ridetrans.it +wss://nostr-2.crypticthreadz.com +wss://test.itas.li +wss://fashionsocial.host +wss://ordinary.cafe +wss://social.arinbasu.online +wss://nostr.crypticthreadz.com +wss://relay.zebedee.cloud +wss://nostr.pub.wellorder.net +wss://09d4-5-161-189-144.ngrok-free.app +wss://andalucia.social +wss://udongein.xyz +wss://squeet.me +wss://mastodon.org.uk +wss://guild.pmdcollab.org +wss://relay.fi +wss://black.nostrscity.club +wss://relay.darker.to +wss://denostr.paiya.app +wss://noste.lu.ke +wss://wss.node01.nostress.cc +wss://theverge.space +wss://swiss.social +wss://relay.webstr.org +wss://nostr.shsbt.xyz +wss://relay.nostr.mom +wss://bitcoinmaximlaists.online +wss://relay.openhoofd.nl +wss://toot.aquilenet.fr +wss://toot.ale.gd +wss://relay.devstr.org +wss://lounge.town +wss://amala.schwartzwelt.xyz +wss://planetasieve.com.br +wss://alcrypt.ru:20911 +wss://webzero.grin.plus:8080 +wss://pylons.lightlns.com:28556 +wss://etourneau.fr:28343 +wss://sentie.relay.rts.network +wss://hermes.boarstudios.com +wss://non-central.pw +wss://nostrum.casa +wss://press.coop +wss://neovibe.app +wss://mstdn.starnix.network +wss://nostr.ameristraliagov.com +wss://cryptodon.chat +wss://umbrell.local:4848 +wss://mastodon.codingfield.com +wss://fe.disroot.org +wss://national.catposting.agency +wss://mastodon.pinewoodroad.net +wss://podcasts.social +wss://20nostr.semisol.dev +wss://nostr.oxtr.net +wss://mstdn.o-nature-culture.net +wss://dearcoati6.lnbits.com +wss://die-partei.social +wss://donotban.com +wss://creative.ai +wss://metaskey.net +wss://spacey.space +wss://node01.nostreess.cc +wss://node01.nostress.co +wss://niscii.xyz +wss://3gkpphcfwb6w5iq6axnmlbvr7pz2t37uy4ofocyijzttzrbz4jy43fid.onion +wss://3gkpphcfwb6w5iq6axnmlbvr7pz2t37uy4ofocyijzttzrbz4jy43fid.local +wss://sportsbots.xyz +wss://videos.lukesmith.xyz +wss://nostr.btcfreedom.ca +wss://gardenstate.social +wss://bg-btc.local:4848 +wss://0fa53e299287.ngrok.app +wss://bird.makeup +wss://nlayer.lbdev.fun +wss://relay.queiroz.vip +wss://mstdn.business +wss://osage.moe +wss://botrelay.com +wss://filter.wine +wss://gingadon.com +wss://noncentral.pw +wss://honi.club +wss://xn--baw-joa.social +wss://nostr.semisol.devwss +wss://relay.iris.to +wss://mynostrrelay.deno.dev +wss://social.heise.de +wss://vavursybkbgfyow7nnst5jnqsj2xyteusf3zeerbjdizq6y7h25v4syd.onion:5051 +wss://vavursybkbgfyow7nnst5jnqsj2xyteusf3zeerbjdizq6y7h25v4syd.onion:5050 +wss://relao.nostr.bg +wss://phpc.social +wss://mastodon.kylerank.in +wss://gameliberty.club +wss://rot.gives +wss://www.nostrweb.xyz +wss://ligma.pro +wss://mastodon.grin.hu +wss://geeknews.chat +wss://devdilettante.com +wss://relay.nosr-latam.link +wss://nosr.bitcoiner.social +wss://raru.re +wss://create-key.net +wss://lgbtqia.space +wss://fluffy.family +wss://mas.town +wss://bird.froth.zone +wss://akkoma.cryptoschizo.club +wss://relay.ramus.io +wss://40two.site +wss://relay.40two.site +wss://vis.social +wss://mk.paritybit.ca +wss://nyan.network +wss://ln.weedstr.net +wss://wikis.world +wss://social.fringe.com +wss://umbraxenu.no-ip.biz +wss://heads.social +wss://tsqdakwo4dh5ej3llsi52ftxfbialteu3jm4cmvxaksl3psbyeoyxxqd.onion +wss://jameliris.to +wss://mastodon.thirring.org +wss://miniwa.moe +wss://welcom.nostr.wine +wss://nostr.vulpem.comwss +wss://relay.semisol.dev +wss://kitsunes.club +wss://songbird.cloud +wss://nostr.wyssblitz.org +wss://libera.tokyo +wss://trpger.us +wss://comam.es +wss://nostr-pub.semisol.devaddittoyourrel +wss://social.dev-wiki.de +wss://ostfrie.se +wss://darmstadt.social +wss://nostr.cx.ms +wss://alentours.cc +wss://nostr.kleofash.eu +wss://gib.social +wss://test23.hifish.org +wss://rapemeat.solutions +wss://filter.stealth.winebroadcasttrue +wss://nostr.montre +wss://grumble.social +wss://nostr.roli.social +wss://primarycare.app +wss://hodlr.rocks +wss://superlinks.me +wss://mastodon.lawprofs.org +wss://tictoc.social +wss://nostest.dojotunnel.online +wss://kafka.icu +wss://nostr.lanparty.one +wss://filter.nostr.wineglobaltrue +wss://social.b10m.net +wss://worm.pink +wss://nostr-test.cx.ms +wss://nostrverifired.com +wss://relay.nostrss.re +wss://eliitin-some.fi +wss://dju.social +wss://jeremy.hu +wss://stream.criminallycute.fi +wss://nostr.0x50.dev +wss://n.s.nyc +wss://n.8.s.nyc +wss://relay.rocks +wss://relay.fiatjaf.com +wss://n-lan.s.nyc +wss://sciences.social +wss://nostr.swiss-enigma.com +wss://quietplace.xyz +wss://universe.nostrich.landlangenlangzh +wss://5280.city +wss://feddit.de +wss://base.lc +wss://social.medusmedia.com +wss://umha4zl6xk62a4dous6e7tq4qlmt462hlzs2su33en6qrvtvs3hkjgid.onion +wss://etorneau.fr:28343 +wss://foggyminds.com +wss://neurodiversity-in.au +wss://nostr.hendrixson.net +wss://ramen-fsm.eu.org +wss://www.nostrical.com +wss://feedbeat.me +wss://relay.bsky.social +wss://babka.social +wss://mastodontech.de +wss://commiespace.duckdns.org +wss://openbiblio.social +wss://karkatdyinginagluetrap.com +wss://relay.fundr.vanderwarker.family +wss://hannover.town +wss://nostr.relay.info +wss://mastodon.tetaneutral.net +wss://cache2.primal.net +wss://nostr.petrkr.net +wss://ifwo.eu +wss://mastodong.lol +wss://venera.social +wss://wallets.fyoumoneypod.com +wss://nostr.relay-nokotaro.com +wss://social.exozy.me +wss://gqgjp2bun4opme6mepz3rrgprkw4xatb6h5ogayorqwi6sajsxcp5sad.local +wss://branle.netlify.app +wss://rsslay.fiat.jaf +wss://chrislace.damus.io +wss://relay.leafbodhi.com +wss://social.opendesktop.org +wss://relay.nostr.watch +wss://gearlandia.haus +wss://freiburg.social +wss://atlas.nostro.land +wss://nostr.io +wss://patrizio.tn.al +wss://chaintools.io +wss://kn.icu +wss://relaywithme.eu +wss://la-autopilot-this-end-up.dvm.email +wss://coinfinity.co +wss://assemblag.es +wss://qou7zzll2mxx2ehl73n6pptmhizl5b3entowljlin3sqhcvltxdtlmad.onion +wss://indigenouscreatives.social +wss://bookwyrm.social +wss://kokoro.shugetsu.space +wss://eden.nost.land +wss://misskey.gothloli.club +wss://sersleepy.com +wss://rsslay.nos.pink +wss://pettingzoo.co +wss://witches.live +wss://7craxnzfi42touzi23etut5qjzqro27sqcuottxj7opntcin4fstruad.onion +wss://mastodonsweden.se +wss://mv2k.com +wss://nostr.tchaicap.space +wss://relay.whoop.ph +wss://bitcoiner.nostr.social +wss://kinkyelephant.com +wss://www.superstork.org +wss://mastodon.iftas.org +wss://lea.pet +wss://zug.network +wss://homeserver.local:4848 +wss://frontrange.co +wss://sciencemastodon.com +wss://montereybay.social +wss://social.securecryptomining.com +wss://rssrelay.nostr.moe +wss://nostr.org +wss://nostr.tw +wss://nostr.hk +wss://wxw.moe +wss://mastodonmusic.social +wss://puntarella.party +wss://oyasumi.space +wss://drumstodon.net +wss://social.wikimedia.de +wss://iyasaretai.pw +wss://social.coletivos.org +wss://nostream.localtest.me +wss://ubuntu201.local:4848 +wss://masto.pt +wss://taiwan.riley-tech.net +wss://freeradical.zone +wss://blastrf7z.xyz +wss://onemorestop.photo +wss://frikiverse.zone +wss://toot.bldrweb.org +wss://electroverse.tech +wss://mstdn.games +wss://relay1.nostr.unitedfop.com +wss://gratefuldread.masto.host +wss://mk.gabe.rocks +wss://widerweb.org +wss://mastodon.eternalaugust.com +wss://lay.southeastasia.cloudapp.azure.com:445 +wss://me.ns.ci +wss://gnostr.th +wss://fritter.cn +wss://nex.cn +wss://nex.tw +wss://fritter.jp +wss://fritter.tw +wss://gnostr.cn +wss://mstdn.dk +wss://akkoma.simulacrum-emporium.eu +wss://dalliance.social +wss://toot.re +wss://avatastic.uk +wss://blastr20f7z.xyz +wss://the.voiceover.bar +wss://porcodon.net +wss://nostr.dncn.xyz +wss://nostr.dnxn.xyz +wss://relay.xmr.rocks +wss://d6egak3woofrixu26gr3utb5qezhkktavsuwlrfqaauu55lmpudxudqd.onion:5051 +wss://social.wuebbsy.com +wss://4kgwkcfzea2xhefsquktyxqyjf3rsxa7oo7hxbcs6k3xdpxznknydsqd.local +wss://4kgwkcfzea2xhefsquktyxqyjf3rsxa7oo7hxbcs6k3xdpxznknydsqd.onion +wss://tooters.org +wss://nostre.wine +wss://relay.xplive.local +wss://relay2.xplive.local +wss://4v5umvicfs6a7d3aiy67uu2ibttiuanl2cehfmv5qaorbojousbgkdad.onion +wss://pipou.academy +wss://opjk6jxrcyicuwhe62tqy6zwx776u7rfi6cqo6iodurjvege7piz5wqd.local +wss://mythology.social +wss://nostr.millou.lol +wss://ryogrid.net:7777 +wss://nost.debancariser.com +wss://fault.stsecurity.moe +wss://jan-optiplex-5040.local:4848 +wss://relay-jp.wirednet.jp +wss://mastodon.kitchen +wss://relay.mnethome.de +wss://verkehrswende.social +wss://nostr.gleeze.com +wss://dair-community.social +wss://shota.house +wss://kavlak.uk +wss://social.inex.rocks +wss://4v5umvicfs6a7d3aiy67uu2ibttiuanl2cehfmv5qaorbojousbgkdad.local +wss://startrekshitposting.com +wss://nostrfmar.ddns.net +wss://4yqp7gzuf15zfc3hpwhz3j5p2uarvdsnf75ovpdiqvyjdsmku771jfid.onion +wss://nostrgraph.net +wss://idolheaven.org +wss://mstdn.kemono-friends.info +wss://mastodon.bawue.social +wss://social.pmj.rocks +wss://ursal.zone +wss://nstr.milou.lol +wss://poweredbygay.social +wss://gochisou.photo +wss://lnb3.openchain.fr +wss://nostr.filmweb.pl +wss://nostr-word.h3z.jp +wss://blastr.f7z.xyzanotherinstanceofblastr +wss://shakedown.social +wss://nostr.bubu.hair +wss://hyper-nostr.inosta.cc +wss://ieji.de +wss://wawmartme.com +wss://nostr.kungfu-g.rip +wss://bozgor.org +wss://todon.nl +wss://nostpy.lol +wss://ostatus.taiyolab.com +wss://polsum.rocks +wss://freespeech.group +wss://im.allmendenetz.de +wss://shitpost.poridge.club +wss://twingyeo.kr +wss://social.platypush.tech +wss://rsslay-production-bc22.up.railway.app +wss://lonely.damus.io +wss://kirche.social +wss://cubalibre.social +wss://relay.txinito.xyz +wss://realy.orangepill.dev +wss://3zi.ru +wss://plebstr.com +wss://social.seattle.wa.us +wss://social.bim.land +wss://cubhub.social +wss://relay.nostr-x.com +wss://hispagatos.space +wss://node101.nostress.cc +wss://lsbt.me +wss://jgqaglhautb4k6e6i2g34jakxiemqp6z4wynlirltuukgkft2xuglmqd.onion +wss://nostdemo.dojotunnel.online +wss://f.cz \ No newline at end of file diff --git a/quartz/src/androidTest/assets/trouble_video b/quartz/src/androidTest/assets/trouble_video new file mode 100644 index 0000000000..401d5e1df2 Binary files /dev/null and b/quartz/src/androidTest/assets/trouble_video differ diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt deleted file mode 100644 index ababe2c5d1..0000000000 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.toHexKey -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class CryptoUtilsTest { - @Test - fun testGetPublicFromPrivateKey() { - val privateKey = - "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() - val publicKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey() - assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey) - } - - @Test - fun testSharedSecretCompatibilityWithCoracle() { - val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561" - val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f" - - val key = - CryptoUtils.nip44.v1.getSharedSecret( - privateKey = privateKey.hexToByteArray(), - pubKey = publicKey.hexToByteArray(), - ) - - assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey()) - } - - @Test - fun testSharedSecret() { - val sender = KeyPair() - val receiver = KeyPair() - - val sharedSecret1 = CryptoUtils.nip44.v1.getSharedSecret(sender.privKey!!, receiver.pubKey) - val sharedSecret2 = CryptoUtils.nip44.v1.getSharedSecret(receiver.privKey!!, sender.pubKey) - - assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey()) - - val secretKey1 = KeyPair(privKey = sharedSecret1) - val secretKey2 = KeyPair(privKey = sharedSecret2) - - assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey()) - assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey()) - } - - @Test - fun encryptDecryptNIP4Test() { - val msg = "Hi" - - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) - - val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) - val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) - - assertEquals(msg, decrypted) - } - - @Test - fun encryptDecryptNIP44v1Test() { - val msg = "Hi" - - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) - - val encrypted = CryptoUtils.nip44.v1.encrypt(msg, privateKey, publicKey) - val decrypted = CryptoUtils.nip44.v1.decrypt(encrypted, privateKey, publicKey) - - assertEquals(msg, decrypted) - } - - @Test - fun encryptSharedSecretDecryptNIP4Test() { - val msg = "Hi" - - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) - - val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) - val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) - - assertEquals(msg, decrypted) - } - - @Test - fun encryptSharedSecretDecryptNIP44v1Test() { - val msg = "Hi" - - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) - val sharedSecret = CryptoUtils.nip44.v1.getSharedSecret(privateKey, publicKey) - - val encrypted = CryptoUtils.nip44.v1.encrypt(msg, sharedSecret) - val decrypted = CryptoUtils.nip44.v1.decrypt(encrypted, sharedSecret) - - assertEquals(msg, decrypted) - } - - @Test - fun signString() { - val random = "319cc5596fdd6cd767e5a59d976e8e059c61306af90dff1e6ee1067b3a1fdbc0".hexToByteArray() - val message = "8e58c8251bb406b6ded69e9eb14f55282a9a53bdab16fc49a3218c2ad3abc887".hexToByteArray() - val keyPair = KeyPair("a5ab474552c8f9c46c2eda5a0b68f27430ad81f96cb405e0cb4e34bf0c6494a2".hexToByteArray()) - - val signedMessage = CryptoUtils.sign(message, keyPair.privKey!!, random).toHexKey() - val expectedValue = "0f9be7e01ba53d5ee6874b9180c7956269fda7a5be424634c3d17b5cfcea6da001be89183876415ba08b7dafa6cff4555e393dc228fb8769b384344e9a27b77c" - assertEquals(expectedValue, signedMessage) - - val message2 = "Hello" - val signedMessage2 = CryptoUtils.signString(message2, keyPair.privKey!!, random).toHexKey() - val expectedValue2 = "7ec8194a585bfb513564113b6b7bfeaafa0254c99d24eaf92280657c2291bab908b1b7bc553c83276a0254aef5041bbe6a50e93381edc4de3d859efa1c3a5a1e" - assertEquals(expectedValue2, signedMessage2) - } -} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt index a80729109e..d86e208e23 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt @@ -24,8 +24,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hasValidSignature import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.verify import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import kotlinx.coroutines.runBlocking @@ -48,7 +48,7 @@ class LargeDBSignatureCheck { var counter = 0 eventArray.forEach { - assertTrue(it.hasValidSignature()) + assertTrue(it.verify()) counter++ } @@ -69,7 +69,7 @@ class LargeDBSignatureCheck { var counter = 0 eventArray.forEach { if (it.sig != "") { - assertTrue(it.hasValidSignature()) + assertTrue(it.verify()) } counter++ } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/bloom/BloomFilter.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/bloom/BloomFilter.kt index d41e4d6284..944d45e07b 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/bloom/BloomFilter.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/bloom/BloomFilter.kt @@ -21,16 +21,17 @@ package com.vitorpamplona.quartz.bloom import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.Sha256Hasher import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue import org.junit.Test import org.junit.runner.RunWith import java.math.BigInteger -import java.security.MessageDigest import java.util.Base64 import java.util.BitSet import java.util.concurrent.locks.ReentrantReadWriteLock @@ -41,20 +42,19 @@ class BloomFilter( private val size: Int, private val rounds: Int, private val bits: BitSet = BitSet(size), - private val salt: ByteArray = CryptoUtils.random(8), + private val salt: ByteArray = RandomInstance.bytes(8), ) { - private val hash = MessageDigest.getInstance("SHA-256") + private val hash = Sha256Hasher() private val lock = ReentrantReadWriteLock() fun add(value: HexKey) = add(value.hexToByteArray()) - fun print(): String { - val builder = StringBuilder() - for (seed in 0 until bits.size()) { - builder.append(if (bits.get(seed)) "1" else "0") + fun print() = + buildString { + for (seed in 0 until bits.size()) { + append(if (bits.get(seed)) "1" else "0") + } } - return builder.toString() - } fun add(value: ByteArray) { lock.write { @@ -82,7 +82,7 @@ class BloomFilter( fun hash( seed: Int, value: ByteArray, - ) = BigInteger(1, hash.digest(value + salt + seed.toByte())) + ) = BigInteger(1, hash.hash(value + salt + seed.toByte())) .remainder(BigInteger.valueOf(size.toLong())) .toInt() @@ -135,7 +135,7 @@ class BloomFilterTest { var failureCounter = 0 for (seed in 0..1000000) { - if (bloomFilter.mightContains(CryptoUtils.pubkeyCreate(CryptoUtils.privkeyCreate()))) { + if (bloomFilter.mightContains(Nip01.pubKeyCreate(Nip01.privKeyCreate()))) { failureCounter++ } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt index e4fa35b219..16acec1024 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt @@ -21,25 +21,26 @@ package com.vitorpamplona.quartz.nip01Core import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.utils.sha256Hash -import fr.acinq.secp256k1.Secp256k1 +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.sha256.sha256 import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith -import java.security.SecureRandom @RunWith(AndroidJUnit4::class) class Nip01Test { - private val nip01 = Nip01(Secp256k1.get(), SecureRandom()) private val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() @Test fun testGetPublicFromPrivateKey() { assertEquals( "7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", - nip01.pubkeyCreate(privateKey).toHexKey(), + Nip01.pubKeyCreate(privateKey).toHexKey(), ) } @@ -48,7 +49,7 @@ class Nip01Test { val key = "e6159851715b4aa6190c22b899b0c792847de0a4435ac5b678f35738351c43b0".hexToByteArray() assertEquals( "029fa4ce8c87ca546b196e6518db80a6780e1bd5552b61f9f17bafee5d4e34e09b", - nip01.compressedPubkeyCreate(key).toHexKey(), + Secp256k1Instance.compressedPubKeyFor(key).toHexKey(), ) } @@ -57,7 +58,7 @@ class Nip01Test { val key = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray() assertEquals( "033dcef7585efbdb68747d919152bd481e21f5e952aaaef5a19604fbd096a93dd5", - nip01.compressedPubkeyCreate(key).toHexKey(), + Secp256k1Instance.compressedPubKeyFor(key).toHexKey(), ) } @@ -65,7 +66,7 @@ class Nip01Test { fun testDeterministicSign() { assertEquals( "1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37", - nip01.signDeterministic(sha256Hash("Test".toByteArray()), privateKey).toHexKey(), + Nip01.sign(sha256("Test".toByteArray()), privateKey, null).toHexKey(), ) } @@ -73,17 +74,17 @@ class Nip01Test { fun testSha256() { assertEquals( "532eaabd9574880dbf76b9b8cc00832c20a6ec113d682299550d7a6e0f345e25", - sha256Hash("Test".toByteArray()).toHexKey(), + sha256("Test".toByteArray()).toHexKey(), ) } @Test fun testDeterministicVerify() { assertTrue( - nip01.verify( + Nip01.verify( "1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37".hexToByteArray(), - sha256Hash("Test".toByteArray()), - nip01.pubkeyCreate(privateKey), + sha256("Test".toByteArray()), + Nip01.pubKeyCreate(privateKey), ), ) } @@ -92,18 +93,18 @@ class Nip01Test { fun testNonDeterministicSign() { assertNotEquals( "1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37", - nip01.sign(sha256Hash("Test".toByteArray()), privateKey).toHexKey(), + Nip01.sign(sha256("Test".toByteArray()), privateKey).toHexKey(), ) } @Test fun testNonDeterministicSignVerify() { - val signature = nip01.sign(sha256Hash("Test".toByteArray()), privateKey) + val signature = Nip01.sign(sha256("Test".toByteArray()), privateKey) assertTrue( - nip01.verify( + Nip01.verify( signature, - sha256Hash("Test".toByteArray()), - nip01.pubkeyCreate(privateKey), + sha256("Test".toByteArray()), + Nip01.pubKeyCreate(privateKey), ), ) } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankProcessorTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt similarity index 64% rename from quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankProcessorTest.kt rename to quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt index 863a2c1471..8789248a53 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankProcessorTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt @@ -18,32 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip13Pow +package com.vitorpamplona.quartz.nip01Core.crypto import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class PoWRankProcessorTest { +class Nip01CryptoTest { @Test - fun setPoW() { - assertEquals(26, PoWRankProcessor.calculatePowRankOf("00000026c91e9fc75fdb95b367776e2594b931cebda6d5ca3622501006669c9e")) - } - - @Test - fun setPoWIfCommited25() { - assertEquals(25, PoWRankProcessor.compute("00000026c91e9fc75fdb95b367776e2594b931cebda6d5ca3622501006669c9e", 25)) - } - - @Test - fun setPoWIfCommited26() { - assertEquals(26, PoWRankProcessor.compute("00000026c91e9fc75fdb95b367776e2594b931cebda6d5ca3622501006669c9e", 26)) - } - - @Test - fun setPoWIfCommited27() { - assertEquals(26, PoWRankProcessor.compute("00000026c91e9fc75fdb95b367776e2594b931cebda6d5ca3622501006669c9e", 27)) + fun testGetPublicFromPrivateKey() { + val privateKey = + "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() + val publicKey = Nip01.pubKeyCreate(privateKey).toHexKey() + assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey) } } 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 05151a22f8..7aa3095f84 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 @@ -20,20 +20,19 @@ */ package com.vitorpamplona.quartz.nip03Timestamp.ots -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair 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 junit.framework.TestCase.assertNotNull import junit.framework.TestCase.fail 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 OtsTest { val otsEvent = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqCiiW0FlsU9lqK5f1A+cL6CGJ1Ah4V/A1yNJY/stUE3wECJz6ng/QxU5Z6xwaMx97qkI//AQqJv8bEMrGTplGWRv5qm4DgjxIHkcQqzpL0Fjr9VBAAijDe0IsQYpOhw1SIjZIgQa6i16CPEEZck7CvAIxR0AloJzCZoAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEOwPtjIkKI1hmtv9t1kuxZcI8QRlyTsK8Ahl0wrCSggZzgCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEE1dVGa8JCuf2ek0c5ybDKII8SCBoVz8Sal45Kd1O8STWIGJTcl5JPtAZBZitqk3BE9MqAjxBGXJOwrwCHoGVgAZi9q9AIPf4w0u+QyOKShodHRwczovL2Zpbm5leS5jYWxlbmRhci5ldGVybml0eXdhbGwuY29t8BAFZFXFYg7DJJ0OzjmJ0FKWCPEEZck7CvAI0M49IcBR5bf/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPEgoE3IfYTmxxo4W/x/QYp/NGX6Wu93gSQkwbpjpOhZORcI8SDWLLurVQaXHdUuwivCfTfuxYCaq+AzypSGqLDAVocrEgjwIBfgjta16y13Gp4etQOCa9YiKEcM+/9AieG/vZolr3IDCPAgMR2zFCb384CEi8tVuI2fHgLT3I9zpe7oqJTzCcEqxWEI8SAJSdgeeosr7IxdOt8r7f0ipWc8FI6GAhgep8zSRgWikAjxIGxYmtCsC79Tx4z4YsT1WuMo+ycMkwhGQsQltF597cchCPAgCLrBf9vR1Aex6yY+vSkXAvLjMKdMqM/a1g8zNPwLeJcI8SBDCbTk4CczTuiIyZeUyYRVh31BZdjaSd2nU/pBQxu+6QjwIOwqE9/WqGC6CHH0i+tr7edvYX5PstSDf08KmnMqsqCoCPEgQIdEBg358vfek3Qjfyrgl51iCU6WUWmThsGLPDTcB0QI8SAX4dp64iI8pBx+zBqAQwUN6XgZ1cEfT8+2vha/9I1vzwjxWQEAAAAB8YJxvxJJth8OnxIV6UOXveIZAcJPTHcAkWgnucpuYqYAAAAAAP3///8CRhoMAAAAAAAWABTUUZK82uAvbU3vVyaaPIOddZBicwAAAAAAAAAAImog8ARCqgwACAjwIB9TcMDLhzgeS1Uw647lNvCfWECkkUvfrrOe6nay0sGdCAjwICYfs90sbPggoMICyOHGYbmOzop2L9mlnh4xqiBLY7yPCAjxINDiVWOBHnRmGJleQdB9myvJAJbNJ9kciZlTOkgJy89mCAjwIHfxqDLdwycj1Vtyth2CaSDdLQwiey9oV6Xov4stLpWNCAjxIGurJYpKJnKp9+y7MAdC+gXgHOiAu5P3RRUFW9l5hGaCCAjxICXqs9hdY0QMP/MNeqlt6s7xaIYtEXZ1CLvou5gaZNEICAjxIHdYxVeI76NXbT2zHcv6lw+v819Ooib7KWxc1GAsiX2fCAjwICPWdi3uBXOlIdmYi+V9C7wAqLyGE4DMoHD+GtvLizqiCAjwIOF2vENtWN5okEMMS+JSf1SGTY9yYP9j0JjXLbC1s+N1CAjxINWsgCtsPxhRNbe372k8/20WDbiL9e8934hGF256DvRECAjwII3mi+Li06j10ORxg0dYMkcsyGb115Jiqq1YEV3K/u+aCAgABYiWDXPXGQEDw9Qy\",\"created_at\":1707690688,\"id\":\"759f9da5846e936fab06766a524b36ba71c03bbc69ad0944fb8ee4bb1f3dd705\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"07c7896c8cbb97b5d7483097590c9d31b73f35c1ad9e752002bb5c1776cbd852e1d32704333d6930c9bc3e40f8b899a1f2e9f91cc3bf797d86acdecba7792576\",\"tags\":[[\"e\",\"a828a25b4165b14f65a8ae5fd40f9c2fa086275021e15fc0d7234963fb2d504d\"],[\"p\",\"595ca8eaace5899cb6ab7e2542bfc972136376f2eabc09287f1857eb8f167e53\"],[\"alt\",\"NIP-03 time stamp\"]]}" val otsEvent2 = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwENyCNtiEN98IzIZgEu3cl6YI//AQ6TkSRd3BTGhDHCK1KkJc+AjxIAHaizG++NNL3Vm13BJrIhT7Br6tEYpb0TVRGaadgiUMCPAgOSDREH9v1Y50UHu79LfC4Lcd9WklQJzRQpw+Unb/pyII8QRltDD58AgqrxfAVrLw7QCD3+MNLvkMji4taHR0cHM6Ly9hbGljZS5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQQMq/CLpGwY60nmddPS7OVgjxIDKxqd9nl+Mej41vP52Wd7gv7004r3n1rFGDObS8icRvCPAgH9TB/kwvXJEEw+h9Ce6fLaI3MORjtTEge0GbAefT6W4I8QRltDD58AhRcoU3gAo/swCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wECWtWsKo0uvSr8BYonjs3DEI8CBlsh2ng1Spl0K4oStYElGuMJsjd2uo5nXB+apo5A7ipwjxIM8oxynBwNA+QS/X7Ebtl1kyhFgfoOQioASNfCBzZ4gaCPEEZbQw+fAId6Yd5cw5gioAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb23wEJmPzXQbxv0AFTIyjTWjMskI8CAurbkrfrBtlinZXSDxj+m/oIkze57hGjTSxu1Xs87XYQjwIPk/LMD0zIgKoEE2dfeoYrrdHuO6dwmghTwUFajH2QzkCPEEZbQw+fAIE+Pq1/Wmdpj/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPAgB2CbqkV7VpjRKIl3Ea6cBmB/EHcSN/YCgcc1E+mc07QI8CAfpkZ2Hh4Rukz3x4il3tZqQtlDlbna+I6so2t2YSEmMQjwIJOv32jbsMa2HJwpleRCKLEhgYOoHCSfpv1ZO0YNNNFsCPAgLMM7eFfCjokQfU4gdU5WpG/wBLkO9lDRF0GktL6ujt8I8SCRxJ0bC1PQ8qFmI/1jh8AS5d1/6VRJNMt1Hz41QmNr3QjwIBmgrKBF+OZ3y+XOMv2E7IZ4WwLr2u2H+ehsBfy7cPlICPEgm4ZMCSXzZVWu40d+zk2edaur6KOauo8X7V2KaFBR1VoI8SBKVVOiyq6IFqGn/15kLwk7L8upMAIZ0znjhYxYqSTQCQjwIMBD1twPZ33GxbwTiuOCeJPkoP++6R2wYpCii8UBTdgwCPAg84VkgMXwrt2xxRoeC1/6CtsFctki+w3m8Rs5/6g/IhEI8CCnzZQDhJyicX7bS7U8PMUObuC9Y4TXe+4THoXBMMXkxwjxIMZ0oAvshpcwowR3qPEDbwKZ6B4NPSU4Hz/+4PnD74gnCPAgIfLZEKqAvkMNXfakXoNq1UVqGSzL4Z86z5GzUfbvw8UI8SC8KoIeLvjd4vJ/xhNVphakPRd80YKeNkeYEuVH8k2EtAjwIPRtinLLxzt8iuw0XZtpTDzEstZOTNYVm+Bi3fEzdeIuCPFZAQAAAAE8vsasINN5DKon0KakX2HNdCB126ZLKXrw4PfvyEfqbwAAAAAA/f///wLPGw8AAAAAABYAFCvgxlh6msa4ZOtgvlc5KiZCx7IvAAAAAAAAAAAiaiDwBKygDAAICPEgB/2DJ3s6gMky/PceGZocTFRXjZUiCCAhHGYwQk/8yrUICPEgDuSd6+PJHUMuEHyLcKFxw7xfvRHRfInjkV3/Zy3BxqAICPEgZDgQ+4VXzlOIkGoO8EVxDgs2cWaeh4EEiaqa/y50gKAICPAgFI+zIuYcMF69GmPQVsXa9oy8eng7MeRZdIxArQyeX3oICPEgX5HYIuImpiSTEapgEssEW4l+W+4aRfNCG3pZf7z0hCoICPEgPkAbOSjFdtS4NT7MXgMYVQoQhI1JZtdFxUu4J3NTt7IICPEguW3qyuyGjctu5d9rM9P9ZCs/ZK4vAc+z21b9ygklgWAICPEgu4e2645xtvGhI1Zzuiv23vRhwE8uC9vj1TAgNg/C8UcICPAgwoQX8X0nY4HoQLRsJ0z8JCWQDzRh2iL2QXEb8z3gbjIICPAgzTwlPRtStsLJWhz3Q/0l8tMnrPSHVuh+zCiGk95dW2MICPAgGcBEuYZyzFNapHOfnJ9Q515QzO2VbIRhlVI0vIhd4jwICPAgidRMoM2pA+KmVJenVrLcbollsbUg9lL9bmv1C1dSxswICPAgZinGakwhbHdanTaRJeBkEUlbhfNokvj8b5KneyG+wzIICAAFiJYNc9cZAQOtwTI=\",\"created_at\":1706324334,\"id\":\"2ad074ddb7724eb13b4244b49cf2321b1057f37fdf8ce102e6329b839cf763a9\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"ad7274bb32ba9e9cfdbd52f4887e8a2fda1047c75a7185b2ab7ff254ebac14ed48a2b60737494d655e24c9400eeeec7e29293a77bfcaafaecd94b350c9a2c22b\",\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"p\",\"c31e22c3715c1bde5608b7e0d04904f22f5fc453ba1806d21c9f2382e1e58c6c\"],[\"alt\",\"NIP-03 time stamp\"]]}" @@ -44,37 +43,39 @@ class OtsTest { @Test fun verifyNostrEvent() { val ots = Event.fromJson(otsEvent) as OtsEvent - println(ots.info()) + println(OtsResolver.info(ots.otsByteArray())) assertEquals(1707688818L, ots.verify()) } @Test fun verifyNostrEvent2() { val ots = Event.Companion.fromJson(otsEvent2) as OtsEvent - println(ots.info()) + println(OtsResolver.info(ots.otsByteArray())) assertEquals(1706322179L, ots.verify()) } @Test fun verifyNostrPendingEvent() { val ots = Event.Companion.fromJson(otsPendingEvent) as OtsEvent - println(ots.info()) + println(OtsResolver.info(ots.otsByteArray())) assertEquals(null, ots.verify()) val eventId = - ots.digestEvent() ?: run { + ots.digestEventId() ?: run { fail("Should not be null") return } - val upgraded = OtsEvent.upgrade(ots.content, eventId) + val upgraded = OtsEvent.upgrade(ots.otsByteArray(), eventId) + + assertNotNull(upgraded) val signer = NostrSignerInternal(KeyPair()) var newOts: OtsEvent? = null val countDownLatch = CountDownLatch(1) - OtsEvent.create(eventId, upgraded, signer) { + signer.sign(OtsEvent.build(eventId, upgraded!!)) { newOts = it countDownLatch.countDown() } @@ -82,7 +83,7 @@ class OtsTest { Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) println(newOts!!.toJson()) - println(newOts!!.info()) + println(OtsResolver.info(newOts!!.otsByteArray())) assertEquals(1708879025L, newOts!!.verify()) } @@ -94,7 +95,7 @@ class OtsTest { val countDownLatch = CountDownLatch(1) - OtsEvent.create(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest), signer) { + signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest))) { ots = it countDownLatch.countDown() } @@ -102,7 +103,7 @@ class OtsTest { Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) println(ots!!.toJson()) - println(ots!!.info()) + println(OtsResolver.info(ots!!.otsByteArray())) assertEquals(null, ots!!.verify()) } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt new file mode 100644 index 0000000000..b79267bf31 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip04Dm + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo +import com.vitorpamplona.quartz.nip04Dm.crypto.Encryption +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class EncryptionTest { + private val nip04 = Encryption() + + val sk1 = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".hexToByteArray() + val sk2 = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".hexToByteArray() + val pk1 = Nip01.pubKeyCreate(sk1) + val pk2 = Nip01.pubKeyCreate(sk2) + + val expectedShared = "7ce22696eb0e303ddaa491bdf2a56b79d249f2d861b8e012a933e01dc4beba81" + + @Test + fun conversationKeyTest() { + assertEquals( + expectedShared, + nip04.computeSharedSecret(sk2, pk1).toHexKey(), + ) + + assertEquals( + expectedShared, + nip04.computeSharedSecret(sk1, pk2).toHexKey(), + ) + } + + @Test + fun encryptDecryptTest() { + val message = "testing" + val cipher = nip04.encrypt(message, sk2, pk1) + + assertEquals(message, nip04.decrypt(cipher, sk2, pk1)) + assertEquals(message, nip04.decrypt(cipher, sk1, pk2)) + + val cipher2 = nip04.encrypt(message, sk1, pk2) + + assertEquals(message, nip04.decrypt(cipher2, sk2, pk1)) + assertEquals(message, nip04.decrypt(cipher2, sk1, pk2)) + } + + @Test + fun decryptTest() { + val cipher = "zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ==" + + assertEquals("nanana", nip04.decrypt(cipher, nip04.computeSharedSecret(sk2, pk1))) + assertEquals("nanana", nip04.decrypt(cipher, nip04.computeSharedSecret(sk1, pk2))) + } + + @Test + fun decryptLargePayloadTest() { + val ciphertext = + "6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g==" + + val expected = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + + assertEquals(expected, nip04.decrypt(ciphertext, nip04.computeSharedSecret(sk2, pk1))) + assertEquals(expected, nip04.decrypt(ciphertext, nip04.computeSharedSecret(sk1, pk2))) + } + + @Test + fun isNIP04Encode() { + assertTrue(EncryptedInfo.isNIP04("Xj/oZZolaItdyQ5v7xYFpA==?iv=+a6zagBp+mr5m1aFbHQ8lA==")) + assertTrue(EncryptedInfo.isNIP04("zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ==")) + assertTrue( + EncryptedInfo.isNIP04("6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g=="), + ) + } + + @Test + fun isNIP04EncodeWithBug() { + assertTrue( + EncryptedInfo.isNIP04( + "QOAYBWa88ConWs2C4kSvNqAcowCtg0ZRtAl7FyLSv9VMaJH4oCiDx0h8VLBnV97HdE4lv" + + "TW7AYC1eEw8/t1dbe0qRc3XrOt7MrPAO8yqpy1/3lFB1+10kip0+KdgT8Quvv02wTP8Dqi" + + "xpr2fliAIG2ONvDn+O5V0q9aVUN9HitgL/myTyR0T42edmxWeZoMBEOKvJyO80FekSsgVL" + + "ASafA/T5z4xs8oG88pSe9wSbSsw0xNjJeh3xLRCLuEuA9KI8hQ1Ys9nEax2UlaB/IL3o77" + + "OwBL+rrdUbNHTxYifgygRhg3BaXMsXRFNJbqYeMaRaNbvHkLVAQV2jLY4P/cKHBjEcTC/f" + + "lrCc2NCYF34rOQUY5EJVnFzM8qYVw6xNupBHTS7WFx1r60cPjG19P/+yoiTZ6bPdHTU0X2" + + "t64ovF2YWUq6/iKAclMaZDhWfrKqf82e62oIff55WQw2bw8A/jtBQVCf66EtEJ2OSFxNaZ" + + "rO+A4oLkHDCnAV+6fYzwo89gPOvORcVvSvg55yGiBFUZx9EHS6kdH1SU80/Mbxe2oI=" + + "?iv=gxz9pUFJFZHuV+D+hgKEOw==-null", + ), + ) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt index 7a5ef8d5f3..29e0acfc8f 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt @@ -20,96 +20,38 @@ */ package com.vitorpamplona.quartz.nip04Dm -import com.vitorpamplona.quartz.nip01Core.Nip01 -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.toHexKey -import fr.acinq.secp256k1.Secp256k1 -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertTrue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 +import org.junit.Assert.assertEquals import org.junit.Test -import java.security.SecureRandom +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class Nip04Test { - private val random = SecureRandom() - private val nip01 = Nip01(Secp256k1.get(), random) - private val nip04 = Nip04(Secp256k1.get(), random) - - val sk1 = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".hexToByteArray() - val sk2 = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".hexToByteArray() - val pk1 = nip01.pubkeyCreate(sk1) - val pk2 = nip01.pubkeyCreate(sk2) - - val expectedShared = "7ce22696eb0e303ddaa491bdf2a56b79d249f2d861b8e012a933e01dc4beba81" - @Test - fun conversationKeyTest() { - assertEquals( - expectedShared, - nip04.computeSharedSecret(sk2, pk1).toHexKey(), - ) + fun encryptDecryptNIP4Test() { + val msg = "Hi" - assertEquals( - expectedShared, - nip04.computeSharedSecret(sk1, pk2).toHexKey(), - ) + val privateKey = Nip01.privKeyCreate() + val publicKey = Nip01.pubKeyCreate(privateKey) + + val encrypted = Nip04.encrypt(msg, privateKey, publicKey) + val decrypted = Nip04.decrypt(encrypted, privateKey, publicKey) + + assertEquals(msg, decrypted) } @Test - fun encryptDecryptTest() { - val message = "testing" - val cipher = nip04.encrypt(message, sk2, pk1) + fun encryptSharedSecretDecryptNIP4Test() { + val msg = "Hi" - assertEquals(message, nip04.decrypt(cipher, sk2, pk1)) - assertEquals(message, nip04.decrypt(cipher, sk1, pk2)) + val privateKey = Nip01.privKeyCreate() + val publicKey = Nip01.pubKeyCreate(privateKey) - val cipher2 = nip04.encrypt(message, sk1, pk2) + val encrypted = Nip04.encrypt(msg, privateKey, publicKey) + val decrypted = Nip04.decrypt(encrypted, privateKey, publicKey) - assertEquals(message, nip04.decrypt(cipher2, sk2, pk1)) - assertEquals(message, nip04.decrypt(cipher2, sk1, pk2)) - } - - @Test - fun decryptTest() { - val cipher = "zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ==" - - assertEquals("nanana", nip04.decrypt(cipher, nip04.computeSharedSecret(sk2, pk1))) - assertEquals("nanana", nip04.decrypt(cipher, nip04.computeSharedSecret(sk1, pk2))) - } - - @Test - fun decryptLargePayloadTest() { - val ciphertext = - "6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g==" - - val expected = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" - - assertEquals(expected, nip04.decrypt(ciphertext, nip04.computeSharedSecret(sk2, pk1))) - assertEquals(expected, nip04.decrypt(ciphertext, nip04.computeSharedSecret(sk1, pk2))) - } - - @Test - fun isNIP04Encode() { - assertTrue(Nip04.isNIP04("Xj/oZZolaItdyQ5v7xYFpA==?iv=+a6zagBp+mr5m1aFbHQ8lA==")) - assertTrue(Nip04.isNIP04("zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ==")) - assertTrue( - Nip04.isNIP04("6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g=="), - ) - } - - @Test - fun isNIP04EncodeWithBug() { - assertTrue( - Nip04.isNIP04( - "QOAYBWa88ConWs2C4kSvNqAcowCtg0ZRtAl7FyLSv9VMaJH4oCiDx0h8VLBnV97HdE4lv" + - "TW7AYC1eEw8/t1dbe0qRc3XrOt7MrPAO8yqpy1/3lFB1+10kip0+KdgT8Quvv02wTP8Dqi" + - "xpr2fliAIG2ONvDn+O5V0q9aVUN9HitgL/myTyR0T42edmxWeZoMBEOKvJyO80FekSsgVL" + - "ASafA/T5z4xs8oG88pSe9wSbSsw0xNjJeh3xLRCLuEuA9KI8hQ1Ys9nEax2UlaB/IL3o77" + - "OwBL+rrdUbNHTxYifgygRhg3BaXMsXRFNJbqYeMaRaNbvHkLVAQV2jLY4P/cKHBjEcTC/f" + - "lrCc2NCYF34rOQUY5EJVnFzM8qYVw6xNupBHTS7WFx1r60cPjG19P/+yoiTZ6bPdHTU0X2" + - "t64ovF2YWUq6/iKAclMaZDhWfrKqf82e62oIff55WQw2bw8A/jtBQVCf66EtEJ2OSFxNaZ" + - "rO+A4oLkHDCnAV+6fYzwo89gPOvORcVvSvg55yGiBFUZx9EHS6kdH1SU80/Mbxe2oI=" + - "?iv=gxz9pUFJFZHuV+D+hgKEOw==-null", - ), - ) + assertEquals(msg, decrypted) } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationTest.kt index a1fef7a1da..9d093fd3b1 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationTest.kt @@ -21,15 +21,14 @@ package com.vitorpamplona.quartz.nip06KeyDerivation import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.toHexKey -import fr.acinq.secp256k1.Secp256k1 +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import junit.framework.TestCase.assertEquals import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class Bip32SeedDerivationTest { - val seedDerivation = Bip32SeedDerivation(Secp256k1.get()) + val seedDerivation = Bip32SeedDerivation() val masterBitcoin = seedDerivation.generate( diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt index 1aaa0eaa5b..5ffb1a116c 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.quartz.nip06KeyDerivation import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.RandomInstance import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import junit.framework.TestCase.fail @@ -69,7 +69,7 @@ class Bip39MnemonicsTest { fun validateMnemonicsValid() { for (i in 0..99) { for (length in listOf(16, 20, 24, 28, 32, 36, 40)) { - val mnemonics = Bip39Mnemonics.toMnemonics(CryptoUtils.random(length)) + val mnemonics = Bip39Mnemonics.toMnemonics(RandomInstance.bytes(length)) Bip39Mnemonics.validate(mnemonics) } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06Test.kt index 2466e84da8..b8eee92b22 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06Test.kt @@ -21,8 +21,7 @@ package com.vitorpamplona.quartz.nip06KeyDerivation import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.toHexKey -import fr.acinq.secp256k1.Secp256k1 +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import junit.framework.TestCase.assertEquals import org.junit.Ignore import org.junit.Test @@ -30,7 +29,7 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class Nip06Test { - val nip06 = Nip06(Secp256k1.get()) + val nip06 = Nip06() // private key (hex): 7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a // nsec: nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/CitationTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/CitationTests.kt index 34ba5aaebb..749d5028e8 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/CitationTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/CitationTests.kt @@ -21,82 +21,49 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds +import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers +import com.vitorpamplona.quartz.nip01Core.verify import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CitationTests { - val json = - """ - { - "content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", - "created_at": 1683596206, - "id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758", - "kind": 1, - "pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", - "sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", - "tags": [ - [ - "e", - "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3", - "", - "root" - ], - [ - "e", - "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc", - "", - "reply" - ], - [ - "p", - "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" - ], - [ - "p", - "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" - ], - [ - "p", - "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" - ], - [ - "p", - "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2" - ], - [ - "p", - "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac" - ], - [ - "p", - "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" - ], - [ - "p", - "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" - ], - [ - "p", - "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" - ], - [ - "p", - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" - ] - ], - "seenOn": [ - "wss://nostr.wine/" - ] -} -""" + val note = + TextNoteEvent( + "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758", + "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", + 1683596206, + arrayOf( + arrayOf("e", "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3", "", "root"), + arrayOf("e", "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc", "", "reply"), + arrayOf("p", "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"), + arrayOf("p", "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"), + arrayOf("p", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"), + arrayOf("p", "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"), + arrayOf("p", "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"), + arrayOf("p", "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"), + arrayOf("p", "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"), + arrayOf("p", "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"), + arrayOf("p", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", + "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", + ) @Test - fun parseEvent() { - val event = EventMapper.fromJson(json) as TextNoteEvent + fun verifyEvent() { + assertTrue(note.verify()) + } + @Test + fun testCitedUsers() { val expectedCitations = setOf( "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", @@ -105,6 +72,75 @@ class CitationTests { "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", ) - assertEquals(expectedCitations, event.citedUsers()) + assertEquals(expectedCitations, note.citedUsers()) + } + + @Test + fun testTagsWithoutCitations() { + val expectedTagsWithoutCitations = + listOf( + "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3", + "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc", + ) + + assertEquals(expectedTagsWithoutCitations, note.tagsWithoutCitations()) + } + + @Test + fun testTaggedEventIds() { + val eventIds = + listOf( + "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3", + "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc", + ) + + assertEquals(eventIds, note.taggedEventIds()) + } + + @Test + fun testTaggedEvents() { + val eventIds = + listOf( + ETag("27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3"), + ETag("be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc"), + ) + + assertEquals(eventIds, note.taggedEvents()) + } + + @Test + fun testTaggedUsers() { + val eventIds = + listOf( + PTag("22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"), + PTag("22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"), + PTag("3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"), + PTag("ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"), + PTag("0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"), + PTag("8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"), + PTag("63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"), + PTag("4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"), + PTag("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ) + + assertEquals(eventIds, note.taggedUsers()) + } + + @Test + fun testTaggedUserIds() { + val eventIds = + listOf( + "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954", + "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", + "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2", + "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", + "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + ) + + assertEquals(eventIds, note.taggedUserIds()) } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt new file mode 100644 index 0000000000..3a8888f004 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt @@ -0,0 +1,340 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import junit.framework.TestCase.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ThreadingTests { + @Test + fun testLegacyEvent() { + val note = + TextNoteEvent( + "", + "", + 0, + arrayOf( + arrayOf("p", "4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0"), + arrayOf("p", "534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), + arrayOf("p", "77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7"), + arrayOf("e", "89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d"), + arrayOf("e", "090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051"), + arrayOf("e", "567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976"), + arrayOf("e", "49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4"), + arrayOf("e", "5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64"), + arrayOf("e", "bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631"), + arrayOf("e", "b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c"), + ), + "", + "", + ) + + val taggedUsers = + listOf( + PTag("4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0"), + PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), + PTag("77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7"), + ) + + assertEquals(taggedUsers, note.taggedUsers()) + + val expectedReply = MarkedETag("b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c", null, MarkedETag.MARKER.REPLY) + + // check replies + assertEquals(null, note.markedReply()) + assertEquals(expectedReply, note.unmarkedReply()) + assertEquals(expectedReply, note.reply()) + + val replyTos = + listOf( + "89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d", + "090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051", + "567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976", + "49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4", + "5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64", + "bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631", + "b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c", + ) + + assertEquals(emptyList(), note.markedReplyTos()) + assertEquals(replyTos, note.unmarkedReplyTos()) + + assertEquals(expectedReply.eventId, note.replyingTo()) + assertEquals(expectedReply.eventId, note.replyingToAddressOrEvent()) + + // check root + val expectedRoot = MarkedETag("89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d", null, MarkedETag.MARKER.ROOT) + + assertEquals(null, note.markedRoot()) + assertEquals(expectedRoot, note.unmarkedRoot()) + assertEquals(expectedRoot, note.root()) + + // check quotes + assertEquals(emptySet(), note.citedUsers()) + assertEquals(emptySet(), note.findCitations()) + assertEquals(replyTos, note.tagsWithoutCitations()) + } + + @Test + fun testMarkedEvent() { + val note = + TextNoteEvent( + "", + "", + 0, + arrayOf( + arrayOf("p", "4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0"), + arrayOf("p", "534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), + arrayOf("e", "77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7"), + arrayOf("e", "bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631", "", "root"), + arrayOf("e", "b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c", "", "reply"), + ), + "", + "", + ) + + val taggedUsers = + listOf( + PTag("4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0"), + PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), + ) + + assertEquals(taggedUsers, note.taggedUsers()) + + val expectedReply = MarkedETag("b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c", null, MarkedETag.MARKER.REPLY) + val expectedReplyWrong = MarkedETag("77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7", null, MarkedETag.MARKER.REPLY) + + // check replies + assertEquals(expectedReply, note.markedReply()) + assertEquals(expectedReplyWrong, note.unmarkedReply()) + assertEquals(expectedReply, note.reply()) + + val markedReplies = + listOf( + "bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631", + "b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c", + ) + + val unmarkedReplies = + listOf( + "77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7", + ) + + assertEquals(markedReplies, note.markedReplyTos()) + assertEquals(unmarkedReplies, note.unmarkedReplyTos()) + + assertEquals(expectedReply.eventId, note.replyingTo()) + assertEquals(expectedReply.eventId, note.replyingToAddressOrEvent()) + + // check root + val expectedRoot = MarkedETag("bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631", null, MarkedETag.MARKER.ROOT) + + val expectedRootWrong = MarkedETag("77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7", null, MarkedETag.MARKER.ROOT) + + assertEquals(expectedRoot, note.markedRoot()) + assertEquals(expectedRootWrong, note.unmarkedRoot()) + assertEquals(expectedRoot, note.root()) + + // check quotes + assertEquals(emptySet(), note.citedUsers()) + assertEquals(emptySet(), note.findCitations()) + assertEquals(markedReplies, note.tagsWithoutCitations()) + } + + @Test + fun testMarkedInverted() { + val note = + TextNoteEvent( + "", + "", + 0, + arrayOf( + arrayOf("p", "4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0", "wss://goiaba.com"), + arrayOf("p", "534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), + arrayOf("p", "77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7"), + arrayOf("e", "5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64", "", "reply"), + arrayOf("e", "bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631", "wss://banana.com", "root", "4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0"), + arrayOf("e", "b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c"), + ), + "", + "", + ) + + val taggedUsers = + listOf( + PTag("4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0", "wss://goiaba.com"), + PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), + PTag("77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7"), + ) + + assertEquals(taggedUsers, note.taggedUsers()) + + val expectedReply = MarkedETag("5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64", null, MarkedETag.MARKER.REPLY) + val expectedReplyWrong = MarkedETag("b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c", null, MarkedETag.MARKER.REPLY) + + // check replies + assertEquals(expectedReply, note.markedReply()) + assertEquals(expectedReplyWrong, note.unmarkedReply()) + assertEquals(expectedReply, note.reply()) + + val markedReplies = + listOf( + "bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631", + "5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64", + ) + + val unmarkedReplies = + listOf( + "b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c", + ) + + assertEquals(markedReplies, note.markedReplyTos()) + assertEquals(unmarkedReplies, note.unmarkedReplyTos()) + + assertEquals(expectedReply.eventId, note.replyingTo()) + assertEquals(expectedReply.eventId, note.replyingToAddressOrEvent()) + + // check root + val expectedRoot = MarkedETag("bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631", null, MarkedETag.MARKER.ROOT) + val expectedRootWrong = MarkedETag("b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c", null, MarkedETag.MARKER.ROOT) + + assertEquals(expectedRoot, note.markedRoot()) + assertEquals(expectedRootWrong, note.unmarkedRoot()) + assertEquals(expectedRoot, note.root()) + + // check quotes + assertEquals(emptySet(), note.citedUsers()) + assertEquals(emptySet(), note.findCitations()) + assertEquals(markedReplies, note.tagsWithoutCitations()) + } + + @Test + fun testOnlyRoot() { + val note = + TextNoteEvent( + "", + "", + 0, + arrayOf( + arrayOf("p", "534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", "wss://banana.com"), + arrayOf("e", "9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590", "", "root", "534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), + ), + "", + "", + ) + + val taggedUsers = + listOf( + PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", "wss://banana.com"), + ) + + assertEquals(taggedUsers, note.taggedUsers()) + + val expectedReply = MarkedETag("9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590", null, MarkedETag.MARKER.REPLY) + + // check replies + assertEquals(null, note.markedReply()) + assertEquals(null, note.unmarkedReply()) + assertEquals(null, note.reply()) + + val replyTos = + listOf( + "9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590", + ) + + assertEquals(replyTos, note.markedReplyTos()) + assertEquals(emptyList(), note.unmarkedReplyTos()) + + assertEquals(expectedReply.eventId, note.replyingTo()) + assertEquals(expectedReply.eventId, note.replyingToAddressOrEvent()) + + // check root + val expectedRoot = MarkedETag("9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590", null, MarkedETag.MARKER.ROOT, "534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec") + + assertEquals(expectedRoot, note.markedRoot()) + assertEquals(null, note.unmarkedRoot()) + assertEquals(expectedRoot, note.root()) + + // check quotes + assertEquals(emptySet(), note.citedUsers()) + assertEquals(emptySet(), note.findCitations()) + assertEquals(replyTos, note.tagsWithoutCitations()) + } + + @Test + fun testOnlyReply() { + val note = + TextNoteEvent( + "", + "", + 0, + arrayOf( + arrayOf("p", "534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", "wss://banana.com"), + arrayOf("e", "9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590", "", "reply", "534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), + ), + "", + "", + ) + + val taggedUsers = + listOf( + PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", "wss://banana.com"), + ) + + assertEquals(taggedUsers, note.taggedUsers()) + + val expectedReply = MarkedETag("9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590", null, MarkedETag.MARKER.REPLY) + + // check replies + assertEquals(expectedReply, note.markedReply()) + assertEquals(null, note.unmarkedReply()) + assertEquals(expectedReply, note.reply()) + + val replyTos = + listOf( + "9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590", + ) + + assertEquals(replyTos, note.markedReplyTos()) + assertEquals(emptyList(), note.unmarkedReplyTos()) + + assertEquals(expectedReply.eventId, note.replyingTo()) + assertEquals(expectedReply.eventId, note.replyingToAddressOrEvent()) + + // check root + val expectedRoot = MarkedETag("9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590", null, MarkedETag.MARKER.ROOT, "534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec") + + assertEquals(null, note.markedRoot()) + assertEquals(null, note.unmarkedRoot()) + assertEquals(null, note.root()) + + // check quotes + assertEquals(emptySet(), note.citedUsers()) + assertEquals(emptySet(), note.findCitations()) + assertEquals(replyTos, note.tagsWithoutCitations()) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWMinerTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWMinerTest.kt new file mode 100644 index 0000000000..80de76babe --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWMinerTest.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip13Pow + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PoWMinerTest { + val baseTemplate = + EventTemplate( + 1683596206, + TextNoteEvent.KIND, + arrayOf( + arrayOf("e", "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3", "", "root"), + arrayOf("e", "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc", "", "reply"), + arrayOf("p", "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"), + arrayOf("p", "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"), + arrayOf("p", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"), + arrayOf("p", "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"), + arrayOf("p", "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"), + arrayOf("p", "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"), + arrayOf("p", "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"), + arrayOf("p", "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"), + arrayOf("p", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", + ) + + @Test + fun mine() { + val signer = NostrSignerSync(KeyPair()) + println("Starting") + val template = PoWMiner.run(baseTemplate, signer.pubKey, 25) + println("Finished") + + val event = signer.sign(template) + + println(event?.toJson()) + + assertEquals(25, event!!.pow()) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankEvaluatorTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankEvaluatorTest.kt new file mode 100644 index 0000000000..2ced5d1296 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankEvaluatorTest.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip13Pow + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PoWRankEvaluatorTest { + val tests = + mapOf( + "000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358" to 21, + "6bf5b4f434813c64b523d2b0e6efe18f3bd0cbbd0a5effd8ece9e00fd2531996" to 1, + "00003479309ecdb46b1c04ce129d2709378518588bed6776e60474ebde3159ae" to 18, + "01a76167d41add96be4959d9e618b7a35f26551d62c43c11e5e64094c6b53c83" to 7, + "ac4f44bae06a45ebe88cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6" to 0, + "0000000000000000006cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6" to 73, + "00000026c91e9fc75fdb95b367776e2594b931cebda6d5ca3622501006669c9e" to 26, + ) + + @Test + fun testHex() { + tests.forEach { + assertEquals(it.value, PoWRankEvaluator.calculatePowRankOf(it.key)) + } + } + + @Test + fun testByte() { + tests.forEach { + assertEquals(it.value, PoWRankEvaluator.calculatePowRankOf(it.key.hexToByteArray())) + } + } + + val commitmentTest = "00000026c91e9fc75fdb95b367776e2594b931cebda6d5ca3622501006669c9e" + + @Test + fun setPoWIfCommited25() { + assertEquals(25, PoWRankEvaluator.compute(commitmentTest, 25)) + } + + @Test + fun setPoWIfCommited26() { + assertEquals(26, PoWRankEvaluator.compute(commitmentTest, 26)) + } + + @Test + fun setPoWIfCommited27() { + assertEquals(26, PoWRankEvaluator.compute(commitmentTest, 27)) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt index 746196fb44..ce15430c9c 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt @@ -22,7 +22,8 @@ package com.vitorpamplona.quartz.nip17Dm import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM import junit.framework.TestCase.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -53,4 +54,22 @@ class AESGCMTest { assertEquals(44201, decrypted.size) } + + @Test + fun videoTest2() { + val myCipher = + AESGCM( + "373d19850ebc8ed5b0fefcca5cd6f27fde9cb6ac54fd32f6b4fad9d68ebe8ee0".hexToByteArray(), + "95e67b6874784a54299b58b8990499bd".hexToByteArray(), + ) + + val encrypted = + getInstrumentation().context.assets.open("trouble_video").use { + it.readAllBytes() + } + + val decrypted = myCipher.decrypt(encrypted) + + assertEquals(1277122, decrypted.size) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyTest.kt index 747271afce..288ec59360 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip17Dm import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.collections.immutable.persistentSetOf import org.junit.Assert.assertEquals import org.junit.Test diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt index 21640fde33..7df8c7a050 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt @@ -22,11 +22,11 @@ package com.vitorpamplona.quartz.nip19Bech32 import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hasValidSignature -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.verify import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.utils.Hex @@ -52,7 +52,9 @@ class NIP19EmbedTests { val countDownLatch = CountDownLatch(1) - TextNoteEvent.create("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size.", isDraft = false, signer = signer) { + signer.sign( + TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."), + ) { textNote = it countDownLatch.countDown() } @@ -67,7 +69,7 @@ class NIP19EmbedTests { val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event - assertTrue(decodedNote.hasValidSignature()) + assertTrue(decodedNote.verify()) assertEquals(textNote!!.toJson(), decodedNote.toJson()) } @@ -83,7 +85,7 @@ class NIP19EmbedTests { val countDownLatch = CountDownLatch(1) - FhirResourceEvent.create(fhirPayload = visionPrescriptionFhir, signer = signer) { + signer.sign(FhirResourceEvent.build(visionPrescriptionFhir)) { eyeglassesPrescriptionEvent = it countDownLatch.countDown() } @@ -99,7 +101,7 @@ class NIP19EmbedTests { val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event - assertTrue(decodedNote.hasValidSignature()) + assertTrue(decodedNote.verify()) assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) } @@ -115,7 +117,7 @@ class NIP19EmbedTests { val countDownLatch = CountDownLatch(1) - FhirResourceEvent.create(fhirPayload = visionPrescriptionBundle, signer = signer) { + signer.sign(FhirResourceEvent.build(visionPrescriptionBundle)) { eyeglassesPrescriptionEvent = it countDownLatch.countDown() } @@ -131,7 +133,7 @@ class NIP19EmbedTests { val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event - assertTrue(decodedNote.hasValidSignature()) + assertTrue(decodedNote.verify()) assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) } @@ -147,7 +149,7 @@ class NIP19EmbedTests { val countDownLatch = CountDownLatch(1) - FhirResourceEvent.create(fhirPayload = visionPrescriptionBundle2, signer = signer) { + signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2)) { eyeglassesPrescriptionEvent = it countDownLatch.countDown() } @@ -163,13 +165,25 @@ class NIP19EmbedTests { val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event - assertTrue(decodedNote.hasValidSignature()) + assertTrue(decodedNote.verify()) assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) } + @Test + fun testTimsNembed() { + val uri = "nembed1r79ssq9446hkwqhl642ukmku8qg0c92pu7w3j0jyfte8tc7tvg85vmrys8x3sqgle5vjy7jpjswqhphl0kd6yf4sz0n3peyjq5rp3zkat4w6c6j3f7um0724jmfu5456xxgg2yxkn8dp23j64xsn9npcggzafyh2effyntqrqxzja8dp52kpcvc9zqxlj86e8mx05vevzxkeprjkfs4wmppxm3p96vj6yvu2mqgf5l4v99492r2qsggquxuv93uzx244652h2kkj8xseg9xkq0afpygknjtty9j4ju5v0nm9mezux9wyl6s5wr7lzce7cj397mnu0u04ha7aq3w7exelrhe3zs3l3urwa9sp36u80npllrs0hmsxqdn0fsuyav3nv0azjs5suzuurg2uymncjxez8p9xksc2j6gw992enjflgrdd7n5uq2xrpvfrd3rckw624ey0elvm6grr27tyzlf4vaswgm5vc3hdyczsl983g2j8e67r6z5zt30lat84ma4wclkwwxxrcflvdsuwd7346h7zqav4vdwe3gkt9lr87sfk4aqd2aey03tt4eyspldrqcmkx9pqe2pn63rv7grwwalr86akuldnvjm6m87wrw9sdwns8wq0rnsmj57vqwtc3g7hkwum3vl2dda78dwkycgfzw6qna3ufhpatcvq5a4hm4ehl45an8umwt0clf7rn77ctke475qglwu86hhfwhn7dkca4pkfpyc4y75rll6nvr5qc8nlhf8mk22celn5mecvyuzxd830drhdck9tcdpcafymk8wajwu2w8ha8gatggjfvq0a4jlf2sdamzj0ysqks9dk8me3q7a0qpmf6vykurkrcls4pug3u4pn4u26ezx3h8e482n07x2nsmu80dpufxqc0ttcyzhnppguxma4d8aumdawnlsyy7yzcuxl7lw5y9p4nv5h8fn6u8anpm2tsze3p6mgxy9j9uuqfxg2jvlmtjpakna5m4hln0msmw804hnun96h66fh62270yhhljnmmdl7jln07ll5vft7e870hemcld34a09n943ed6629fgtctsftma9q6tf4jfm2p0ukd2j2n2dpz53fqrkk4ctdcy2j5jar095g5jntf6u807ggkzauzt6uqkwk4tg5w7w55kskspc9663zx5dzzzfwpg3q546g2ve4kukr70n0a46eyce2crsqqq247ql5" + + val decodedNote = (Nip19Parser.uriToRoute(uri)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(timsPrescription, decodedNote.toJson()) + } + val visionPrescriptionFhir = "{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"Patient/Donald Duck\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"Practitioner/Adam Careful\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}" val visionPrescriptionBundle = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Careful\",\"given\":[\"Adam\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Duck\",\"given\":[\"Donald\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"#2\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" val visionPrescriptionBundle2 = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Smith\",\"given\":[\"Dr. Joe\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Doe\",\"given\":[\"Jane\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + val timsPrescription = "{\"id\":\"18d8b22e6455dfc9f4c6d6be8c2cf015e961b8d160dfe5e4b7fc1578f2c4e0be\",\"pubkey\":\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\",\"created_at\":1739566773,\"kind\":82,\"tags\":[[\"p\",\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\"]],\"content\":\"{\\\"resourceType\\\": \\\"VisionPrescription\\\", \\\"id\\\": \\\"eyeglass-prescription-001\\\", \\\"status\\\": \\\"active\\\", \\\"created\\\": \\\"2025-02-14T10:00:00Z\\\", \\\"patient\\\": {\\\"reference\\\": \\\"Patient/12345\\\", \\\"display\\\": \\\"John Doe\\\"}, \\\"encounter\\\": {\\\"reference\\\": \\\"Encounter/67890\\\"}, \\\"dateWritten\\\": \\\"2025-02-10T15:00:00Z\\\", \\\"prescriber\\\": {\\\"reference\\\": \\\"Practitioner/56789\\\", \\\"display\\\": \\\"Dr. Emily Smith\\\"}, \\\"lensSpecification\\\": [{\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"right\\\", \\\"sphere\\\": -2.5, \\\"cylinder\\\": -1.0, \\\"axis\\\": 180, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"up\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Right eye prescription for near-sightedness with astigmatism.\\\"}]}, {\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"left\\\", \\\"sphere\\\": -3.0, \\\"cylinder\\\": -0.75, \\\"axis\\\": 160, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"down\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Left eye prescription for near-sightedness with astigmatism.\\\"}]}]}\",\"sig\":\"d22d3b86aea397094de8b6cdf69decdfd886c90008aeebf95fd43a2770b37d486b313bff5bdb44b78e33bbaa3f336d74ee8b36bc5b16050374054246c72d93c2\"}" } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt new file mode 100644 index 0000000000..611c9abf5f --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip44Encryption + +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class Nip44v1Test { + private val nip44v1 = Nip44v1() + + @Test + fun testSharedSecretCompatibilityWithCoracle() { + val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561" + val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f" + + val key = + nip44v1.getSharedSecret( + privateKey = privateKey.hexToByteArray(), + pubKey = publicKey.hexToByteArray(), + ) + + assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey()) + } + + @Test + fun testSharedSecret() { + val sender = KeyPair() + val receiver = KeyPair() + + val sharedSecret1 = nip44v1.getSharedSecret(sender.privKey!!, receiver.pubKey) + val sharedSecret2 = nip44v1.getSharedSecret(receiver.privKey!!, sender.pubKey) + + assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey()) + + val secretKey1 = KeyPair(privKey = sharedSecret1) + val secretKey2 = KeyPair(privKey = sharedSecret2) + + assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey()) + assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey()) + } + + @Test + fun encryptDecrypt() { + val msg = "Hi" + + val privateKey = Nip01.privKeyCreate() + val publicKey = Nip01.pubKeyCreate(privateKey) + + val encrypted = nip44v1.encrypt(msg, privateKey, publicKey) + val decrypted = nip44v1.decrypt(encrypted, privateKey, publicKey) + + assertEquals(msg, decrypted) + } + + @Test + fun encryptDecryptSharedSecret() { + val msg = "Hi" + + val privateKey = Nip01.privKeyCreate() + val publicKey = Nip01.pubKeyCreate(privateKey) + + val sharedSecret = nip44v1.getSharedSecret(privateKey, publicKey) + + val encrypted = nip44v1.encrypt(msg, sharedSecret) + val decrypted = nip44v1.decrypt(encrypted, sharedSecret) + + assertEquals(msg, decrypted) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/NIP44v2Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt similarity index 87% rename from quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/NIP44v2Test.kt rename to quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt index 29e867baaf..dba7e58cfb 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/NIP44v2Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt @@ -23,22 +23,21 @@ package com.vitorpamplona.quartz.nip44Encryption import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.Nip01 -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.toHexKey -import com.vitorpamplona.quartz.utils.sha256Hash -import fr.acinq.secp256k1.Secp256k1 +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.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertNull import junit.framework.TestCase.fail import org.junit.Test import org.junit.runner.RunWith -import java.security.SecureRandom @RunWith(AndroidJUnit4::class) -class NIP44v2Test { +class Nip44v2Test { private val vectors: VectorFile = jacksonObjectMapper() .readValue( @@ -46,9 +45,7 @@ class NIP44v2Test { VectorFile::class.java, ) - private val random = SecureRandom() - private val nip44v2 = Nip44v2(Secp256k1.get(), random) - private val nip01 = Nip01(Secp256k1.get(), random) + private val nip44v2 = Nip44v2() @Test fun conversationKeyTest() { @@ -73,8 +70,8 @@ class NIP44v2Test { val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() val privateKeyB = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray() - val publicKeyA = nip01.pubkeyCreate(privateKeyA) - val publicKeyB = nip01.pubkeyCreate(privateKeyB) + val publicKeyA = Nip01.pubKeyCreate(privateKeyA) + val publicKeyB = Nip01.pubKeyCreate(privateKeyB) assertEquals( nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(), @@ -87,8 +84,8 @@ class NIP44v2Test { val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() val privateKeyB = "e6159851715b4aa6190c22b899b0c792847de0a4435ac5b678f35738351c43b0".hexToByteArray() - val publicKeyA = nip01.pubkeyCreate(privateKeyA) - val publicKeyB = nip01.pubkeyCreate(privateKeyB) + val publicKeyA = Nip01.pubKeyCreate(privateKeyA) + val publicKeyB = Nip01.pubKeyCreate(privateKeyB) assertEquals( nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(), @@ -149,8 +146,7 @@ class NIP44v2Test { @Test fun invalidMessageLengths() { for (v in vectors.v2?.invalid?.encryptMsgLengths!!) { - val key = ByteArray(32) - random.nextBytes(key) + val key = RandomInstance.bytes(32) try { nip44v2.encrypt("a".repeat(v), key) fail("Should Throw for $v") @@ -185,5 +181,5 @@ class NIP44v2Test { } } - private fun sha256Hex(data: ByteArray) = sha256Hash(data).toHexKey() + private fun sha256Hex(data: ByteArray) = sha256(data).toHexKey() } 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 b7968c0f71..2050af360f 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.assertEquals import org.junit.Ignore @@ -219,7 +219,7 @@ internal class Nip46Test { @Test fun testRelaysResponse() { - val expected = BunkerResponseGetRelays(relays = mapOf("url" to ContactListEvent.ReadWrite(true, false))) + val expected = BunkerResponseGetRelays(relays = mapOf("url" to ReadWrite(true, false))) val actual = encodeDecodeEvent(expected) assertEquals(expected.id, actual.id) diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt index 3985842c0c..8c39bf8654 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt @@ -21,14 +21,12 @@ package com.vitorpamplona.quartz.nip49PrivKeyEnc import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.toHexKey -import fr.acinq.secp256k1.Secp256k1 +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.fail import org.junit.Test import org.junit.runner.RunWith -import java.security.SecureRandom @RunWith(AndroidJUnit4::class) public class NIP49Test { @@ -53,8 +51,7 @@ public class NIP49Test { ) } - val random = SecureRandom() - val nip49 = Nip49(Secp256k1.get(), random) + val nip49 = Nip49() @Test fun decodeBech32() { diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt new file mode 100644 index 0000000000..cbf2311167 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner + +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SignStringTest { + @Test + fun signString() { + val random = "319cc5596fdd6cd767e5a59d976e8e059c61306af90dff1e6ee1067b3a1fdbc0".hexToByteArray() + val message = "8e58c8251bb406b6ded69e9eb14f55282a9a53bdab16fc49a3218c2ad3abc887".hexToByteArray() + val keyPair = KeyPair("a5ab474552c8f9c46c2eda5a0b68f27430ad81f96cb405e0cb4e34bf0c6494a2".hexToByteArray()) + + val signedMessage = Nip01.sign(message, keyPair.privKey!!, random).toHexKey() + val expectedValue = "0f9be7e01ba53d5ee6874b9180c7956269fda7a5be424634c3d17b5cfcea6da001be89183876415ba08b7dafa6cff4555e393dc228fb8769b384344e9a27b77c" + assertEquals(expectedValue, signedMessage) + + val message2 = "Hello" + val signedMessage2 = signString(message2, keyPair.privKey!!, random).toHexKey() + val expectedValue2 = "7ec8194a585bfb513564113b6b7bfeaafa0254c99d24eaf92280657c2291bab908b1b7bc553c83276a0254aef5041bbe6a50e93381edc4de3d859efa1c3a5a1e" + assertEquals(expectedValue2, signedMessage2) + } +} 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 d8a96b0b3a..eb696ff7a2 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip57Zaps import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip01Core.toHexKey import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEncryption.Companion.createEncryptionPrivateKey import com.vitorpamplona.quartz.nip59GiftWraps.wait1SecondForResult import com.vitorpamplona.quartz.utils.Hex 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 25a697ebb4..62bcbf3bcc 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt @@ -21,17 +21,18 @@ package com.vitorpamplona.quartz.nip59GiftWraps import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.checkSignature import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +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.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +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 org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals @@ -54,9 +55,11 @@ class GiftWrapEventTest { // Requires 3 tests val countDownLatch = CountDownLatch(3) - NIP17Factory().createMsgNIP17( - message, - listOf(receiver.pubKey), + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + listOf(PTag(receiver.pubKey, null)), + ), sender, ) { events -> countDownLatch.countDown() @@ -114,9 +117,11 @@ class GiftWrapEventTest { val countDownLatch = CountDownLatch(receivers.size + 2) - NIP17Factory().createMsgNIP17( - message, - receivers.map { it.pubKey }, + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + receivers.map { PTag(it.pubKey, null) }, + ), sender, ) { events -> countDownLatch.countDown() @@ -167,11 +172,11 @@ class GiftWrapEventTest { var giftWrapEventToSender: GiftWrapEvent? = null var giftWrapEventToReceiver: GiftWrapEvent? = null - ChatMessageEvent.create( - msg = "Hi There!", - isDraft = false, - to = listOf(receiver.pubKey), - signer = sender, + sender.sign( + ChatMessageEvent.build( + msg = "Hi There!", + to = listOf(PTag(receiver.pubKey, null)), + ), ) { senderMessage -> // MsgFor the Receiver @@ -306,11 +311,11 @@ class GiftWrapEventTest { var giftWrapEventToReceiverA: GiftWrapEvent? = null var giftWrapEventToReceiverB: GiftWrapEvent? = null - ChatMessageEvent.create( - msg = "Who is going to the party tonight?", - isDraft = false, - to = listOf(receiverA.pubKey, receiverB.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, diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt index 2b6d50ce01..42686132d0 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.utils import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.CryptoUtils import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -56,7 +55,7 @@ class HexEncodingTest { @Test fun testRandoms() { for (i in 0..1000) { - val bytes = CryptoUtils.privkeyCreate() + val bytes = RandomInstance.bytes(32) val hex = fr.acinq.secp256k1.Hex .encode(bytes) @@ -90,7 +89,7 @@ class HexEncodingTest { @Test fun testRandomsIsHex() { for (i in 0..10000) { - val bytes = CryptoUtils.privkeyCreate() + val bytes = RandomInstance.bytes(32) val hex = bytes.toHexString(HexFormat.Default) assertTrue(hex, Hex.isHex(hex)) val hexUpper = bytes.toHexString(HexFormat.UpperCase) @@ -102,7 +101,7 @@ class HexEncodingTest { @Test fun testRandomsUppercase() { for (i in 0..1000) { - val bytes = CryptoUtils.privkeyCreate() + val bytes = RandomInstance.bytes(32) val hex = bytes.toHexString(HexFormat.UpperCase) assertEquals( bytes.toList(), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/CryptoUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/CryptoUtils.kt deleted file mode 100644 index 08dab6a150..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/CryptoUtils.kt +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz - -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.Nip01 -import com.vitorpamplona.quartz.nip04Dm.Nip04 -import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 -import com.vitorpamplona.quartz.nip44Encryption.Nip44 -import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 -import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 -import com.vitorpamplona.quartz.utils.nextBytes -import com.vitorpamplona.quartz.utils.sha256Hash -import fr.acinq.secp256k1.Secp256k1 -import java.security.SecureRandom - -object CryptoUtils { - private val secp256k1 = Secp256k1.get() - private val random = SecureRandom() - - val nip01 = Nip01(secp256k1, random) - val nip06 = Nip06(secp256k1) - val nip04 = Nip04(secp256k1, random) - val nip44 = Nip44(secp256k1, random, nip04) - val nip49 = Nip49(secp256k1, random) - - fun clearCache() { - nip04.clearCache() - nip44.clearCache() - } - - /** Provides a 32B "private key" aka random number */ - fun privkeyCreate() = nip01.privkeyCreate() - - fun pubkeyCreate(privKey: ByteArray) = nip01.pubkeyCreate(privKey) - - fun randomInt(bound: Int) = random.nextInt(bound) - - fun random(size: Int) = random.nextBytes(size) - - fun signString( - message: String, - privKey: ByteArray, - auxrand32: ByteArray = random(32), - ): ByteArray = nip01.signString(message, privKey, auxrand32) - - fun sign( - data: ByteArray, - privKey: ByteArray, - auxrand32: ByteArray? = null, - ): ByteArray = nip01.sign(data, privKey, auxrand32) - - fun verifySignature( - signature: ByteArray, - hash: ByteArray, - pubKey: ByteArray, - ): Boolean = nip01.verify(signature, hash, pubKey) - - fun sha256(data: ByteArray): ByteArray { - // Creates a new buffer every time - return sha256Hash(data) - } - - fun decrypt( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? = - if (Nip04.isNIP04(msg)) { - decryptNIP04(msg, privateKey, pubKey) - } else { - decryptNIP44(msg, privateKey, pubKey) - } - - /** NIP 04 Utils */ - fun encryptNIP04( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String = nip04.encrypt(msg, privateKey, pubKey) - - fun encryptNIP04( - msg: String, - sharedSecret: ByteArray, - ): Nip04.EncryptedInfo = nip04.encrypt(msg, sharedSecret) - - fun decryptNIP04( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String = nip04.decrypt(msg, privateKey, pubKey) - - fun decryptNIP04( - encryptedInfo: Nip04.EncryptedInfo, - privateKey: ByteArray, - pubKey: ByteArray, - ): String = nip04.decrypt(encryptedInfo, privateKey, pubKey) - - fun decryptNIP04( - msg: String, - sharedSecret: ByteArray, - ): String = nip04.decrypt(msg, sharedSecret) - - private fun decryptNIP04( - cipher: String, - nonce: String, - sharedSecret: ByteArray, - ): String = nip04.decrypt(cipher, nonce, sharedSecret) - - private fun decryptNIP04( - encryptedMsg: ByteArray, - iv: ByteArray, - sharedSecret: ByteArray, - ): String = nip04.decrypt(encryptedMsg, iv, sharedSecret) - - fun getSharedSecretNIP04( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray = nip04.getSharedSecret(privateKey, pubKey) - - fun computeSharedSecretNIP04( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray = nip04.computeSharedSecret(privateKey, pubKey) - - /** NIP 06 Utils */ - fun isValidMnemonic(mnemonic: String): Boolean = nip06.isValidMnemonic(mnemonic) - - fun privateKeyFromMnemonic( - mnemonic: String, - account: Long = 0, - ) = nip06.privateKeyFromMnemonic(mnemonic, account) - - /** NIP 44 Utils */ - fun getSharedSecretNIP44( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray = nip44.getSharedSecret(privateKey, pubKey) - - fun computeSharedSecretNIP44( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray = nip44.computeSharedSecret(privateKey, pubKey) - - fun encryptNIP44( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): Nip44v2.EncryptedInfo = nip44.encrypt(msg, privateKey, pubKey) - - fun decryptNIP44( - payload: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? = nip44.decrypt(payload, privateKey, pubKey) - - /** NIP 49 Utils */ - fun decryptNIP49( - payload: String, - password: String, - ): String? { - if (payload.isEmpty() || password.isEmpty()) return null - return nip49.decrypt(payload, password) - } - - fun encryptNIP49( - key: HexKey, - password: String, - ): String = nip49.encrypt(key, password) -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt index 78d830d3c1..19a1db44f8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt @@ -22,49 +22,49 @@ package com.vitorpamplona.quartz import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.blossom.BlossomServersEvent -import com.vitorpamplona.quartz.experimental.audio.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.AudioTrackEvent +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.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent -import com.vitorpamplona.quartz.experimental.nip95.FileStorageEvent -import com.vitorpamplona.quartz.experimental.nip95.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.EventHasher -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.MetadataEvent 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.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip04Dm.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEncryptedFileHeaderEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelHideMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.ChannelListEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMuteUserEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip34Git.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.GitReplyEvent -import com.vitorpamplona.quartz.nip34Git.GitRepositoryEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent @@ -82,8 +82,8 @@ import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -92,28 +92,27 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip71Video.VideoViewEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.CommunityListEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.AppRecommendationEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -import com.vitorpamplona.quartz.nip96FileStorage.FileServersEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -251,7 +250,6 @@ class EventFactory { TorrentCommentEvent.KIND -> TorrentCommentEvent(id, pubKey, createdAt, tags, content, sig) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) - VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) else -> { factories[kind]?.let { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt index 2108ac6f27..cc8cd8f3e3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.blossom import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.utils.TimeUtils diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt index 0c0e265d86..3bb4f13a02 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.quartz.blossom import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +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.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -50,15 +51,17 @@ class BlossomServersEvent( const val KIND = 10063 const val ALT = "File servers used by the 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 = ATag.assembleATagId(KIND, pubKey, FIXED_D_TAG) + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) fun createTagArray(servers: List): Array> = servers .map { arrayOf("server", it) - }.plusElement(AltTagSerializer.toTagArray(ALT)) + }.plusElement(AltTag.assemble(ALT)) .toTypedArray() fun updateRelayList( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/AudioHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/AudioHeaderEvent.kt deleted file mode 100644 index 6326bd2536..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/AudioHeaderEvent.kt +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.experimental.audio - -import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class AudioHeaderEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun download() = tags.firstOrNull { it.size > 1 && it[0] == DOWNLOAD_URL }?.get(1) - - fun stream() = tags.firstOrNull { it.size > 1 && it[0] == STREAM_URL }?.get(1) - - fun wavefrom() = - tags - .firstOrNull { it.size > 1 && it[0] == WAVEFORM } - ?.get(1) - ?.let { EventMapper.mapper.readValue>(it) } - - companion object { - const val KIND = 1808 - const val ALT = "Audio header" - - private const val DOWNLOAD_URL = "download_url" - private const val STREAM_URL = "stream_url" - private const val WAVEFORM = "waveform" - - fun create( - description: String, - downloadUrl: String, - streamUrl: String? = null, - wavefront: String? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AudioHeaderEvent) -> Unit, - ) { - val tags = - listOfNotNull( - downloadUrl.let { arrayOf(DOWNLOAD_URL, it) }, - streamUrl?.let { arrayOf(STREAM_URL, it) }, - wavefront?.let { arrayOf(WAVEFORM, it) }, - sensitiveContent?.let { - if (it) { - ContentWarningSerializer.toTagArray() - } else { - null - } - }, - AltTagSerializer.toTagArray(ALT), - ).toTypedArray() - - signer.sign(createdAt, KIND, tags, description, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackSelectionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt similarity index 50% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackSelectionEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt index 9a223590bb..396be396a1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackSelectionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt @@ -18,47 +18,51 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip30CustomEmoji +package com.vitorpamplona.quartz.experimental.audio.header import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.experimental.audio.header.tags.DownloadUrlTag +import com.vitorpamplona.quartz.experimental.audio.header.tags.StreamUrlTag +import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class EmojiPackSelectionEvent( +class AudioHeaderEvent( id: HexKey, pubKey: HexKey, createdAt: Long, tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun download() = tags.firstNotNullOfOrNull(DownloadUrlTag::parse) + + fun stream() = tags.firstNotNullOfOrNull(StreamUrlTag::parse) + + fun wavefrom() = tags.firstNotNullOfOrNull(WaveformTag::parse) + companion object { - const val KIND = 10030 - const val ALT = "Emoji selection" + const val KIND = 1808 + const val ALT = "Audio header" - fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null) - - fun createAddressTag(pubKey: HexKey) = ATag.assembleATagId(KIND, pubKey, FIXED_D_TAG) - - fun create( - listOfEmojiPacks: List?, - signer: NostrSigner, + fun build( + description: String, + downloadUrl: String, + streamUrl: String? = null, + wavefront: List? = null, createdAt: Long = TimeUtils.now(), - onReady: (EmojiPackSelectionEvent) -> Unit, - ) { - val msg = "" - val tags = mutableListOf>() - - listOfEmojiPacks?.forEach { tags.add(arrayOf("a", it.toTag())) } - - tags.add(AltTagSerializer.toTagArray(ALT)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + alt(ALT) + downloadUrl.let { downloadUrl(it) } + streamUrl?.let { streamUrl(it) } + wavefront?.let { wavefront(it) } + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..78280af77a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/TagArrayBuilderExt.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.header + +import com.vitorpamplona.quartz.experimental.audio.header.tags.DownloadUrlTag +import com.vitorpamplona.quartz.experimental.audio.header.tags.StreamUrlTag +import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.downloadUrl(downloadUrlTag: String) = addUnique(DownloadUrlTag.assemble(downloadUrlTag)) + +fun TagArrayBuilder.streamUrl(streamUrl: String) = addUnique(StreamUrlTag.assemble(streamUrl)) + +fun TagArrayBuilder.wavefront(wave: List) = addUnique(WaveformTag.assemble(wave)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/DownloadUrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/DownloadUrlTag.kt new file mode 100644 index 0000000000..5f335b67bd --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/DownloadUrlTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.header.tags + +class DownloadUrlTag { + companion object { + const val TAG_NAME = "download_url" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/StreamUrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/StreamUrlTag.kt new file mode 100644 index 0000000000..d3bc35de7c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/StreamUrlTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.header.tags + +class StreamUrlTag { + companion object { + const val TAG_NAME = "stream_url" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt new file mode 100644 index 0000000000..0942299f24 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.header.tags + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper + +class WaveformTag( + val wave: List, +) { + fun toTagArray() = assemble(wave) + + companion object { + const val TAG_NAME = "waveform" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): WaveformTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[1].isEmpty()) return null + val wave = runCatching { EventMapper.mapper.readValue>(tag[1]) }.getOrNull() + if (wave.isNullOrEmpty()) return null + return WaveformTag(wave) + } + + @JvmStatic + fun assemble(wave: List) = arrayOf(TAG_NAME, EventMapper.mapper.writeValueAsString(wave)) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/AudioTrackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt similarity index 52% rename from quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/AudioTrackEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt index 25c04be6c6..dfd0414018 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/AudioTrackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt @@ -18,14 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.experimental.audio +package com.vitorpamplona.quartz.experimental.audio.track import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.experimental.audio.track.tags.CoverTag +import com.vitorpamplona.quartz.experimental.audio.track.tags.MediaTag +import com.vitorpamplona.quartz.experimental.audio.track.tags.ParticipantTag +import com.vitorpamplona.quartz.experimental.audio.track.tags.PriceTag +import com.vitorpamplona.quartz.experimental.audio.track.tags.TypeTag import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID @Immutable class AudioTrackEvent( @@ -36,53 +44,40 @@ class AudioTrackEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) } + fun participants() = tags.mapNotNull(ParticipantTag::parse) - fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) + fun type() = tags.firstNotNullOfOrNull(TypeTag::parse) - fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1) + fun price() = tags.firstNotNullOfOrNull(PriceTag::parse) - fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1) + fun cover() = tags.firstNotNullOfOrNull(CoverTag::parse) - // fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1) - fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1) + fun media() = tags.firstNotNullOfOrNull(MediaTag::parse) companion object { const val KIND = 31337 - const val ALT = "Audio track" + const val ALT_DESCRIPTION = "Audio track" - private const val TYPE = "c" - private const val PRICE = "price" - private const val COVER = "cover" - private const val SUBJECT = "subject" - private const val MEDIA = "media" - - fun create( + fun build( type: String, media: String, price: String? = null, cover: String? = null, subject: String? = null, - signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), - onReady: (AudioTrackEvent) -> Unit, - ) { - val tags = - listOfNotNull( - arrayOf(MEDIA, media), - arrayOf(TYPE, type), - price?.let { arrayOf(PRICE, it) }, - cover?.let { arrayOf(COVER, it) }, - subject?.let { arrayOf(SUBJECT, it) }, - AltTagSerializer.toTagArray(ALT), - ).toTypedArray() + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(dTag) + alt(ALT_DESCRIPTION) - signer.sign(createdAt, KIND, tags, "", onReady) + type(type) + media(media) + price?.let { price(it) } + cover?.let { cover(it) } + subject?.let { subject(it) } + + initializer() } } } - -@Immutable data class Participant( - val key: String, - val role: String?, -) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..027b02c9a7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/TagArrayBuilderExt.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.track + +import com.vitorpamplona.quartz.experimental.audio.track.tags.CoverTag +import com.vitorpamplona.quartz.experimental.audio.track.tags.MediaTag +import com.vitorpamplona.quartz.experimental.audio.track.tags.PriceTag +import com.vitorpamplona.quartz.experimental.audio.track.tags.TypeTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip14Subject.SubjectTag + +fun TagArrayBuilder.price(price: String) = addUnique(PriceTag.assemble(price)) + +fun TagArrayBuilder.cover(coverUrl: String) = addUnique(CoverTag.assemble(coverUrl)) + +fun TagArrayBuilder.media(mediaUrl: String) = addUnique(MediaTag.assemble(mediaUrl)) + +fun TagArrayBuilder.type(type: String) = addUnique(TypeTag.assemble(type)) + +fun TagArrayBuilder.subject(subject: String) = addUnique(SubjectTag.assemble(subject)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/CoverTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/CoverTag.kt new file mode 100644 index 0000000000..e52b81d2e3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/CoverTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.track.tags + +class CoverTag { + companion object { + const val TAG_NAME = "cover" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/MediaTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/MediaTag.kt new file mode 100644 index 0000000000..d620dbe45f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/MediaTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.track.tags + +class MediaTag { + companion object { + const val TAG_NAME = "media" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt new file mode 100644 index 0000000000..1701e908f0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.track.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.isNotName +import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +@Immutable +data class ParticipantTag( + override val pubKey: String, + override val relayHint: String?, +) : PubKeyReferenceTag { + fun toTagArray() = assemble(pubKey, relayHint) + + companion object { + const val TAG_NAME = "p" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Tag): ParticipantTag? { + if (tag.isNotName(TAG_NAME, TAG_SIZE)) return null + if (tag[1].length != 64) return null + return ParticipantTag(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseKey(tag: Tag): String? { + if (tag.isNotName(TAG_NAME, TAG_SIZE)) return null + if (tag[1].length != 64) return null + return tag[1] + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: String?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/PriceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/PriceTag.kt new file mode 100644 index 0000000000..c0439e8dfd --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/PriceTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.track.tags + +class PriceTag { + companion object { + const val TAG_NAME = "price" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/TypeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/TypeTag.kt new file mode 100644 index 0000000000..fd3a227c62 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/TypeTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.audio.track.tags + +class TypeTag { + companion object { + const val TAG_NAME = "c" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/BountyAddValueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/BountyAddValueEvent.kt new file mode 100644 index 0000000000..822da28530 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/BountyAddValueEvent.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.bounties + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip10Notes.tags.markedETag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import java.math.BigDecimal + +/** + * This is a huge hack from back in the days... + */ +class BountyAddValueEvent { + companion object { + const val KIND = 1 + const val ALT_DESCRIPTION = "Add Value to Bounty" + + const val BOUNTY_HASH_TAG = "bounty-added-reward" + + fun build( + amount: BigDecimal, + bountyRoot: EventHintBundle, + bountyRootAuthor: PTag, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val tags = TagArrayBuilder() + tags.markedETag(bountyRoot.toMarkedETag(MarkedETag.MARKER.ROOT)) + tags.hashtag(BOUNTY_HASH_TAG) + tags.pTag(bountyRootAuthor) + tags.alt(ALT_DESCRIPTION) + return EventTemplate(createdAt, KIND, tags.build(), amount.toString()) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/EventExt.kt index 94a4a603d9..1b1e37669c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/EventExt.kt @@ -21,7 +21,15 @@ package com.vitorpamplona.quartz.experimental.bounties import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.firstMapTagged import java.math.BigDecimal -fun Event.getReward(): BigDecimal? = tags.firstMapTagged("reward") { runCatching { BigDecimal(it[1]) }.getOrNull() } +fun Event.bountyBaseReward(): BigDecimal? = tags.bountyBaseReward() + +fun Event.hasAdditionalReward(): Boolean = tags.hasAdditionalReward() + +fun Event.addedRewardValue(): BigDecimal? = + if (hasAdditionalReward()) { + runCatching { BigDecimal(content) }.getOrNull() + } else { + null + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/RewardTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/RewardTag.kt new file mode 100644 index 0000000000..e8f3acd97e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/RewardTag.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.bounties + +import java.math.BigDecimal + +class RewardTag { + companion object { + const val TAG_NAME = "reward" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): BigDecimal? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + + return runCatching { BigDecimal(tag[1]) }.getOrNull() + } + + @JvmStatic + fun assemble(amount: BigDecimal) = arrayOf(TAG_NAME, amount.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/TagArrayExt.kt new file mode 100644 index 0000000000..c659d42928 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/TagArrayExt.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.bounties + +import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent.Companion.BOUNTY_HASH_TAG +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash + +fun TagArray.bountyBaseReward() = this.firstNotNullOfOrNull(RewardTag::parse) + +fun TagArray.hasAdditionalReward() = this.isTaggedHash(BOUNTY_HASH_TAG) 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 index e15f7cfa21..bd418eec3a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt @@ -23,14 +23,15 @@ package com.vitorpamplona.quartz.experimental.edits import android.util.Log import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -100,11 +101,13 @@ class PrivateOutboxRelayListEvent( companion object { const val KIND = 10013 - val TAGS = arrayOf(AltTagSerializer.toTagArray("Relay list to store private content from this author")) + 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 = ATag.assembleATagId(KIND, pubKey, FIXED_D_TAG) + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) fun encryptTags( privateTags: Array>? = null, 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 116b01825f..f5f25ceff8 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 @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.experimental.edits import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.tags.events.firstTaggedEvent -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -64,7 +64,7 @@ class TextNoteModificationEvent( tags.add(arrayOf("summary", it)) } - tags.add(AltTagSerializer.toTagArray(ALT)) + tags.add(AltTag.assemble(ALT)) signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt new file mode 100644 index 0000000000..6db5f7739a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.forks + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag + +fun BaseThreadedEvent.isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } + +fun BaseThreadedEvent.forkFromAddress() = + tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "fork" }?.let { + val aTagValue = it[1] + Address.parse(aTagValue) + } + +fun BaseThreadedEvent.forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseFork) + +fun BaseThreadedEvent.isForkFromAddressWithPubkey(authorHex: HexKey) = tags.any { it.size > 3 && it[0] == "a" && it[3] == "fork" && it[1].contains(authorHex) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt new file mode 100644 index 0000000000..9a3217b0e8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.forks + +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.MARKER + +fun MarkedETag.Companion.parseFork(tag: Array): MarkedETag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[ORDER_MARKER] != MARKER.FORK.code) return null + // ["e", id hex, relay hint, marker, pubkey] + return MarkedETag( + tag[ORDER_EVT_ID], + tag[ORDER_RELAY], + tag[ORDER_MARKER], + tag.getOrNull( + ORDER_PUBKEY, + ), + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/inlineMetadata/Nip54InlineMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/inlineMetadata/Nip54InlineMetadata.kt index b09f6fb999..9c3822ab78 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/inlineMetadata/Nip54InlineMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/inlineMetadata/Nip54InlineMetadata.kt @@ -20,28 +20,22 @@ */ package com.vitorpamplona.quartz.experimental.inlineMetadata -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import java.net.URI import java.net.URLDecoder import java.net.URLEncoder import kotlin.coroutines.cancellation.CancellationException class Nip54InlineMetadata { - fun createUrl(header: IMetaTag): String = - createUrl( - header.url, - header.properties, - ) - fun createUrl( url: String, - tags: Map, + tags: Map>, ): String { val extension = tags .mapNotNull { - if (it.key != "url") { - "${it.key}=${URLEncoder.encode(it.value, "utf-8")}" + val value = it.value.firstOrNull() + if (it.key != "url" && value != null) { + "${it.key}=${URLEncoder.encode(value, "utf-8")}" } else { null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt index 7446884a4b..f1aa08c29c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt @@ -20,23 +20,12 @@ */ package com.vitorpamplona.quartz.experimental.interactiveStories -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.buildHashtagTags -import com.vitorpamplona.quartz.nip10Notes.content.buildUrlRefs -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag open class InteractiveStoryBaseEvent( id: HexKey, @@ -47,75 +36,11 @@ open class InteractiveStoryBaseEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun title() = tags.firstTagValue("title") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun summary() = tags.firstTagValue("summary") + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - fun image() = tags.firstTagValue("image") + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun options() = - tags - .filter { it.size > 2 && it[0] == "option" } - .mapNotNull { ATag.parse(it[2], it.getOrNull(3))?.let { aTag -> StoryOption(it[1], aTag) } } - - companion object { - fun generalTags( - content: String, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - ): Array> { - val tags = mutableListOf>() - - tags.addAll(buildHashtagTags(findHashtags(content))) - tags.addAll(buildUrlRefs(findURLs(content))) - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) - } - - geohash?.let { tags.addAll(geohashMipMap(it)) } - imetas?.forEach { - tags.add(Nip92MediaAttachments.createTag(it)) - } - emojis?.forEach { tags.add(it.toTagArray()) } - return tags.toTypedArray() - } - - fun makeTags( - baseId: String, - alt: String, - title: String, - summary: String? = null, - image: String? = null, - options: List = emptyList(), - ): Array> = - ( - listOfNotNull( - arrayOf("d", baseId), - arrayOf("title", title), - summary?.let { arrayOf("summary", it) }, - image?.let { arrayOf("image", it) }, - AltTagSerializer.toTagArray(alt), - ) + - options.map { - val relayUrl = it.address.relay - if (relayUrl != null) { - arrayOf("option", it.option, it.address.toTag(), relayUrl) - } else { - arrayOf("option", it.option, it.address.toTag()) - } - } - ).toTypedArray() - } + fun options() = tags.mapNotNull(StoryOptionTag::parse) } - -class StoryOption( - val option: String, - val address: ATag, -) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryPrologueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryPrologueEvent.kt index 3cbffc1cba..489567c6ab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryPrologueEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryPrologueEvent.kt @@ -20,12 +20,15 @@ */ package com.vitorpamplona.quartz.experimental.interactiveStories -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip22Comments.RootScope -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.TimeUtils class InteractiveStoryPrologueEvent( @@ -39,44 +42,31 @@ class InteractiveStoryPrologueEvent( RootScope { companion object { const val KIND = 30296 - const val ALT = "The prologue of an interative story called " + const val ALT = "The prologue of an interactive story called " + + fun createAddress( + pubKey: HexKey, + dtag: String, + ): String = Address.assemble(KIND, pubKey, dtag) fun createAddressATag( pubKey: HexKey, dtag: String, ): ATag = ATag(KIND, pubKey, dtag, null) - fun createAddressTag( - pubKey: HexKey, - dtag: String, - ): String = ATag.assembleATagId(KIND, pubKey, dtag) - - fun create( + fun build( baseId: String, title: String, content: String, - options: List, - summary: String? = null, - image: String? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - geohash: String? = null, - imetas: List? = null, - signer: NostrSigner, + options: List, createdAt: Long = TimeUtils.now(), - isDraft: Boolean, - onReady: (InteractiveStoryPrologueEvent) -> Unit, - ) { - val tags = - makeTags(baseId, ALT + title, title, summary, image, options) + - generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, imetas) - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags, content, onReady) - } else { - signer.sign(createdAt, KIND, tags, content, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate = + eventTemplate(KIND, content, createdAt) { + dTag(baseId) + title(title) + options(options) + initializer() } - } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt index 1002f5ca70..9ee1e3f0b9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt @@ -21,15 +21,23 @@ package com.vitorpamplona.quartz.experimental.interactiveStories import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.ReadStatusTag +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.RootSceneTag import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.firstTag -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.StatusTag import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @Immutable class InteractiveStoryReadingStateEvent( @@ -40,23 +48,28 @@ class InteractiveStoryReadingStateEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun title() = tags.firstTagValue("title") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun summary() = tags.firstTagValue("summary") + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - fun image() = tags.firstTagValue("image") + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun status() = tags.firstTagValue("status") + fun status() = tags.firstNotNullOfOrNull(StatusTag::parse) - fun root() = tags.firstTag("A")?.let { ATag.parse(it[1], it.getOrNull(2)) } + fun root() = tags.firstNotNullOfOrNull(RootSceneTag::parse) - fun currentScene() = tags.firstTag("a")?.let { ATag.parse(it[1], it.getOrNull(2)) } + fun currentScene() = tags.firstNotNullOfOrNull(ATag::parseAddress) companion object { const val KIND = 30298 const val ALT1 = "Interactive Story Reading state" const val ALT2 = "The reading state of " + fun createAddress( + pubKey: HexKey, + dtag: String, + ): Address = Address(KIND, pubKey, dtag) + fun createAddressATag( pubKey: HexKey, dtag: String, @@ -65,77 +78,66 @@ class InteractiveStoryReadingStateEvent( fun createAddressTag( pubKey: HexKey, dtag: String, - ): String = ATag.assembleATagId(KIND, pubKey, dtag) + ): String = Address.assemble(KIND, pubKey, dtag) fun update( base: InteractiveStoryReadingStateEvent, currentScene: InteractiveStoryBaseEvent, currentSceneRelay: String?, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (InteractiveStoryReadingStateEvent) -> Unit, - ) { + ): EventTemplate { val rootTag = base.dTag() - val sceneTag = currentScene.addressTag() + val sceneTag = currentScene.aTag(currentSceneRelay) val status = - if (rootTag == sceneTag) { - "new" + if (rootTag == sceneTag.toTag()) { + ReadStatusTag.STATUS.NEW } else if (currentScene.options().isEmpty()) { - "done" + ReadStatusTag.STATUS.DONE } else { - "reading" + ReadStatusTag.STATUS.READING } - val tags = - base.tags.filter { it[0] != "a" && it[0] != "status" } + - listOf( - removeTrailingNullsAndEmptyOthers("a", sceneTag, currentSceneRelay), - arrayOf("status", status), - ) + val updatedTags = + base.tags.builder { + currentScene(sceneTag) + status(status) + } - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + return EventTemplate(createdAt, KIND, updatedTags, "") } - fun create( + fun build( root: InteractiveStoryBaseEvent, rootRelay: String?, currentScene: InteractiveStoryBaseEvent, currentSceneRelay: String?, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (InteractiveStoryReadingStateEvent) -> Unit, - ) { - val rootTag = root.addressTag() - val sceneTag = currentScene.addressTag() + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + val rootTag = root.aTag(rootRelay) + val sceneTag = currentScene.aTag(currentSceneRelay) val status = if (rootTag == sceneTag) { - "new" + ReadStatusTag.STATUS.NEW } else if (currentScene.options().isEmpty()) { - "done" + ReadStatusTag.STATUS.DONE } else { - "reading" + ReadStatusTag.STATUS.READING } - val tags = - listOfNotNull( - arrayOf("d", rootTag), - AltTagSerializer.toTagArray(root.title()?.let { ALT2 + it } ?: ALT1), - root.title()?.let { arrayOf("title", it) }, - root.summary()?.let { arrayOf("summary", it) }, - root.image()?.let { arrayOf("image", it) }, - removeTrailingNullsAndEmptyOthers("A", rootTag, rootRelay), - removeTrailingNullsAndEmptyOthers("a", sceneTag, currentSceneRelay), - arrayOf("status", status), - ).toTypedArray() + dTag(rootTag.toTag()) + alt(root.title()?.let { ALT2 + it } ?: ALT1) - signer.sign(createdAt, KIND, tags, "", onReady) + rootScene(rootTag) + currentScene(sceneTag) + status(status) + + root.title()?.let { storyTitle(it) } + root.summary()?.let { storyImage(it) } + root.image()?.let { storySummary(it) } + + initializer() } } - - enum class ReadingStatus { - NEW, - READING, - DONE, - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStorySceneEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStorySceneEvent.kt index 3b2b46d9fc..341da104aa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStorySceneEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStorySceneEvent.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.quartz.experimental.interactiveStories -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip22Comments.RootScope -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.TimeUtils class InteractiveStorySceneEvent( @@ -49,32 +51,20 @@ class InteractiveStorySceneEvent( fun createAddressTag( pubKey: HexKey, dtag: String, - ): String = ATag.assembleATagId(KIND, pubKey, dtag) + ): String = Address.assemble(KIND, pubKey, dtag) - fun create( + fun build( baseId: String, title: String, content: String, - options: List, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - geohash: String? = null, - imetas: List? = null, - signer: NostrSigner, + options: List, createdAt: Long = TimeUtils.now(), - isDraft: Boolean, - onReady: (InteractiveStorySceneEvent) -> Unit, - ) { - val tags = - makeTags(baseId, ALT + title, title, options = options) + - generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, imetas) - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags, content, onReady) - } else { - signer.sign(createdAt, KIND, tags, content, onReady) - } + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + dTag(baseId) + title(title) + options(options) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..0028687866 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.interactiveStories + +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.ReadStatusTag +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.option(option: StoryOptionTag) = add(option.toTagArray()) + +fun TagArrayBuilder.options(options: List) = addAll(options.map { it.toTagArray() }) + +fun TagArrayBuilder.publishedAt(publishedAt: Long) = addUnique(PublishedAtTag.assemble(publishedAt)) + +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.image(imageUrl: String) = add(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.images(imageUrls: List) = addAll(imageUrls.map { ImageTag.assemble(it) }) + +fun TagArrayBuilder.storyTitle(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.storySummary(summary: String) = addUnique(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.storyImage(imageUrl: String) = add(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.storyImages(imageUrls: List) = addAll(imageUrls.map { ImageTag.assemble(it) }) + +fun TagArrayBuilder.rootScene(scene: ATag) = addUnique(scene.toATagArray()) + +fun TagArrayBuilder.currentScene(scene: ATag) = addUnique(scene.toATagArray()) + +fun TagArrayBuilder.status(status: ReadStatusTag.STATUS) = addUnique(status.toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/ReadStatusTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/ReadStatusTag.kt new file mode 100644 index 0000000000..c09d4e7683 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/ReadStatusTag.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.interactiveStories.tags + +class ReadStatusTag { + enum class STATUS( + val value: String, + ) { + NEW("new"), + READING("reading"), + DONE("done"), + ; + + fun toTagArray() = assemble(this) + } + + companion object { + const val TAG_NAME = "status" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(status: STATUS) = arrayOf(TAG_NAME, status.value) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt new file mode 100644 index 0000000000..01062321ed --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.interactiveStories.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers + +@Immutable +data class RootSceneTag( + val kind: Int, + val pubKeyHex: String, + val dTag: String, +) { + var relay: String? = null + + constructor( + kind: Int, + pubKeyHex: HexKey, + dTag: String, + relayHint: String?, + ) : this(kind, pubKeyHex, dTag) { + this.relay = relayHint + } + + fun countMemory(): Long = + 5 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit) + 8L + // kind + pubKeyHex.bytesUsedInMemory() + + dTag.bytesUsedInMemory() + + (relay?.bytesUsedInMemory() ?: 0) + + fun toTag() = assembleATagId(kind, pubKeyHex, dTag) + + fun toTagArray() = removeTrailingNullsAndEmptyOthers(TAG_NAME, toTag(), relay) + + companion object { + const val TAG_NAME = "A" + const val TAG_SIZE = 2 + + fun assembleATagId( + kind: Int, + pubKeyHex: HexKey, + dTag: String, + ) = Address.assemble(kind, pubKeyHex, dTag) + + @JvmStatic + fun parse(tag: Array): RootSceneTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val address = Address.parse(tag[1]) ?: return null + return RootSceneTag(address.kind, address.pubKeyHex, address.dTag, tag.getOrNull(2)) + } + + @JvmStatic + fun assemble( + aTagId: HexKey, + relay: String?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay) + + @JvmStatic + fun assemble( + kind: Int, + pubKeyHex: String, + dTag: String, + relay: String?, + ) = arrayOfNotNull(TAG_NAME, assembleATagId(kind, pubKeyHex, dTag), relay) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/StoryOptionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/StoryOptionTag.kt new file mode 100644 index 0000000000..652e681293 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/StoryOptionTag.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.interactiveStories.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip46RemoteSigner.getOrNull +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class StoryOptionTag( + val option: String, + val address: Address, + val relay: String?, +) { + fun toTagArray() = assemble(option, address, relay) + + companion object { + const val TAG_NAME = "option" + const val TAG_SIZE = 3 + + @JvmStatic + fun parse(tag: Array): StoryOptionTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[2].isNotEmpty()) { return null } + val address = Address.parse(tag[2]) ?: return null + return StoryOptionTag(tag[1], address, tag.getOrNull(3)) + } + + @JvmStatic + fun assemble( + title: String, + address: Address, + relay: String?, + ) = arrayOfNotNull(TAG_NAME, title, address.toValue(), relay) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt index efe076e091..f3c829d9f1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.experimental.limits import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip13Pow.pow import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt index abac112599..0fbbfc0c4c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt @@ -21,9 +21,12 @@ package com.vitorpamplona.quartz.experimental.medical import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent.Companion.ALT import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -37,16 +40,15 @@ class FhirResourceEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 82 + const val ALT_DESCRIPTION = "Medical data" - fun create( + fun build( fhirPayload: String, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FhirResourceEvent) -> Unit, - ) { - val tags = mutableListOf>() - - signer.sign(createdAt, KIND, tags.toTypedArray(), fhirPayload, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, fhirPayload, createdAt) { + alt(ALT) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/FileStorageHeaderEvent.kt deleted file mode 100644 index b21bdd6f86..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/FileStorageHeaderEvent.kt +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.experimental.nip95 - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class FileStorageHeaderEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - - fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) - - fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) - - fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) - - fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } - - fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) - - fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) - - fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) - - fun isOneOf(mimeTypes: Set) = tags.any { it.size > 1 && it[0] == FileHeaderEvent.MIME_TYPE && mimeTypes.contains(it[1]) } - - companion object { - const val KIND = 1065 - const val ALT_DESCRIPTION = "Descriptors for a binary file" - - private const val ENCRYPTION_KEY = "aes-256-gcm" - private const val MIME_TYPE = "m" - private const val FILE_SIZE = "size" - private const val DIMENSION = "dim" - private const val HASH = "x" - private const val MAGNET_URI = "magnet" - private const val TORRENT_INFOHASH = "i" - private const val BLUR_HASH = "blurhash" - private const val ALT = "alt" - - fun create( - storageEvent: FileStorageEvent, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileStorageHeaderEvent) -> Unit, - ) { - val tags = - listOfNotNull( - arrayOf("e", storageEvent.id), - mimeType?.let { arrayOf(MIME_TYPE, mimeType) }, - hash?.let { arrayOf(HASH, it) }, - alt?.let { arrayOf(ALT, it) } ?: AltTagSerializer.toTagArray(ALT_DESCRIPTION), - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it.toString()) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - sensitiveContent?.let { - if (it) { - ContentWarningSerializer.toTagArray() - } else { - null - } - }, - ) - - val content = alt ?: "" - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/FileStorageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/FileStorageEvent.kt similarity index 62% rename from quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/FileStorageEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/FileStorageEvent.kt index 9bcb44ad04..00d51dc0d3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/FileStorageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/FileStorageEvent.kt @@ -18,14 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.experimental.nip95 +package com.vitorpamplona.quartz.experimental.nip95.data -import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils import java.util.Base64 @@ -40,40 +41,26 @@ class FileStorageEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { override fun isContentEncoded() = true - fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) - - fun decode(): ByteArray? = - try { - Base64.getDecoder().decode(content) - } catch (e: Exception) { - Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content") - null - } + fun decode(): ByteArray? = decode(content) companion object { const val KIND = 1064 const val ALT = "Binary data" - private const val TYPE = "type" - private const val DECRYPT = "decrypt" + fun decode(content: String): ByteArray? = runCatching { Base64.getDecoder().decode(content) }.getOrNull() fun encode(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) - fun create( - mimeType: String, + fun build( data: ByteArray, - signer: NostrSigner, + mimeType: String? = null, createdAt: Long = TimeUtils.now(), - onReady: (FileStorageEvent) -> Unit, - ) { - val tags = - listOfNotNull( - arrayOf(TYPE, mimeType), - AltTagSerializer.toTagArray(ALT), - ) - - val content = encode(data) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate = + eventTemplate(KIND, encode(data), createdAt) { + alt(ALT) + mimeType?.let { mimeType(it) } + initializer() + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d7f082826d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.nip95.data + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag + +fun TagArrayBuilder.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt new file mode 100644 index 0000000000..0642578926 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.nip95.header + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +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.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class FileStorageHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun dataEvent() = tags.mapNotNull(ETag::parse) + + fun dataEventIds() = tags.mapNotNull(ETag::parseId) + + fun dataEventId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) + + fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) + + fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) + + fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) + + fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse) + + fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse) + + fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) + + fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse) + + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + + fun fallback() = tags.firstNotNullOfOrNull(FallbackTag::parse) + + fun isOneOf(mimeTypes: Set) = tags.any(MimeTypeTag::isIn, mimeTypes) + + companion object { + const val KIND = 1065 + const val ALT_DESCRIPTION = "Descriptors for a binary file" + + fun build( + storageEvent: EventHintBundle, + caption: String?, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, caption ?: "", createdAt) { + eTag(storageEvent.toETag()) + caption?.ifBlank { null }?.let { alt(caption) } ?: alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..f4ec26cbee --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/TagArrayBuilderExt.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.nip95.header + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash + +fun TagArrayBuilder.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType)) + +fun TagArrayBuilder.hash(hash: HexKey) = add(HashSha256Tag.assemble(hash)) + +fun TagArrayBuilder.fileSize(size: Int) = add(SizeTag.assemble(size)) + +fun TagArrayBuilder.dimension(dim: DimensionTag) = add(DimensionTag.assemble(dim)) + +fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) + +fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) + +fun TagArrayBuilder.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri)) + +fun TagArrayBuilder.image(imageUrl: HexKey) = add(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.thumb(trumbUrl: HexKey) = add(ThumbTag.assemble(trumbUrl)) + +fun TagArrayBuilder.summary(summary: HexKey) = add(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.fallback(fallbackUrl: HexKey) = add(FallbackTag.assemble(fallbackUrl)) + +fun TagArrayBuilder.service(service: HexKey) = add(ServiceTag.assemble(service)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/NNSEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/NNSEvent.kt index dac617f416..ee5886cbde 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/NNSEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/NNSEvent.kt @@ -21,10 +21,14 @@ package com.vitorpamplona.quartz.experimental.nns import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.experimental.nns.tags.IPv4Tag +import com.vitorpamplona.quartz.experimental.nns.tags.IPv6Tag +import com.vitorpamplona.quartz.experimental.nns.tags.VersionTag import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -36,23 +40,32 @@ class NNSEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun ip4() = tags.firstOrNull { it.size > 1 && it[0] == "ip4" }?.get(1) + fun ip4() = tags.firstNotNullOfOrNull(IPv4Tag::parse) - fun ip6() = tags.firstOrNull { it.size > 1 && it[0] == "ip6" }?.get(1) + fun ip6() = tags.firstNotNullOfOrNull(IPv6Tag::parse) - fun version() = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.get(1) + fun version() = tags.firstNotNullOfOrNull(VersionTag::parse) companion object { const val KIND = 30053 const val ALT = "DNS records" - fun create( - signer: NostrSigner, + fun build( createdAt: Long = TimeUtils.now(), - onReady: (NNSEvent) -> Unit, - ) { - val tags = arrayOf(AltTagSerializer.toTagArray(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT) + initializer() + } + + fun build( + ipv4: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT) + ipv4(ipv4) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..6411d24bb3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/TagArrayBuilderExt.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.nns + +import com.vitorpamplona.quartz.experimental.nns.tags.IPv4Tag +import com.vitorpamplona.quartz.experimental.nns.tags.IPv6Tag +import com.vitorpamplona.quartz.experimental.nns.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.ipv4(ip: String) = add(IPv4Tag.assemble(ip)) + +fun TagArrayBuilder.ipv6(ip: String) = add(IPv6Tag.assemble(ip)) + +fun TagArrayBuilder.version(version: String) = add(VersionTag.assemble(version)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv4Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv4Tag.kt new file mode 100644 index 0000000000..c8f7ded139 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv4Tag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.nns.tags + +class IPv4Tag { + companion object { + const val TAG_NAME = "ip4" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(ip: String) = arrayOf(TAG_NAME, ip) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv6Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv6Tag.kt new file mode 100644 index 0000000000..07086b2743 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv6Tag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.nns.tags + +class IPv6Tag { + companion object { + const val TAG_NAME = "ip6" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(ip: String) = arrayOf(TAG_NAME, ip) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/VersionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/VersionTag.kt new file mode 100644 index 0000000000..2eca2f76cc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/VersionTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.nns.tags + +class VersionTag { + companion object { + const val TAG_NAME = "version" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(ip: String) = arrayOf(TAG_NAME, ip) + } +} 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 69a8c389bc..ab99c19779 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 @@ -21,10 +21,10 @@ package com.vitorpamplona.quartz.experimental.profileGallery import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +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.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -142,7 +142,7 @@ class GalleryListEvent( if (tags.any { it.size > 1 && it[0] == "alt" }) { tags } else { - tags + AltTagSerializer.toTagArray(ALT) + tags + AltTag.assemble(ALT) } signer.sign(createdAt, KIND, newTags, content, onReady) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt index f0f36824af..124b4fd9b1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt @@ -21,12 +21,26 @@ package com.vitorpamplona.quartz.experimental.profileGallery import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -38,97 +52,54 @@ class ProfileGalleryEntryEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) - fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + fun urls() = tags.mapNotNull(UrlTag::parse) - fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) - fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) - fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) - fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) + fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } + fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse) - fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse) - fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) - fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) - fun fromEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse) - fun hasFromEvent() = tags.any { it.size > 1 && it[0] == "e" } + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - fun isOneOf(mimeTypes: Set) = tags.any { it.size > 1 && it[0] == MIME_TYPE && mimeTypes.contains(it[1]) } + fun fallback() = tags.firstNotNullOfOrNull(FallbackTag::parse) + + fun hasUrl() = tags.any(UrlTag::isTag) + + fun isOneOf(mimeTypes: Set) = tags.any(MimeTypeTag::isIn, mimeTypes) + + fun fromEvent() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun hasFromEvent() = tags.any(ETag::isTagged) companion object { const val KIND = 1163 const val ALT_DESCRIPTION = "Profile Gallery Entry" - const val URL = "url" - const val ENCRYPTION_KEY = "aes-256-gcm" - const val MIME_TYPE = "m" - const val FILE_SIZE = "size" - const val DIMENSION = "dim" - const val HASH = "x" - const val MAGNET_URI = "magnet" - const val TORRENT_INFOHASH = "i" - const val BLUR_HASH = "blurhash" - const val ORIGINAL_HASH = "ox" - const val ALT = "alt" - - fun create( + fun build( url: String, - eventid: String? = null, - relayhint: String? = null, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ProfileGalleryEntryEvent) -> Unit, - ) { - var etag = eventid?.let { arrayOf("e", it) } - relayhint?.let { etag = etag?.plus(it) } - - val tags = - listOfNotNull( - arrayOf(URL, url), - eventid?.let { etag }, - magnetUri?.let { arrayOf(MAGNET_URI, it) }, - mimeType?.let { arrayOf(MIME_TYPE, it) }, - alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: AltTagSerializer.toTagArray(ALT_DESCRIPTION), - hash?.let { arrayOf(HASH, it) }, - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it.toString()) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - sensitiveContent?.let { - if (it) { - ContentWarningSerializer.toTagArray() - } else { - null - } - }, - ) - - val content = alt ?: "" - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + url(url) + alt(ALT_DESCRIPTION) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..afa46548b9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.profileGallery + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag + +fun TagArrayBuilder.url(url: String) = add(UrlTag.assemble(url)) + +fun TagArrayBuilder.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType)) + +fun TagArrayBuilder.hash(hash: HexKey) = add(HashSha256Tag.assemble(hash)) + +fun TagArrayBuilder.fileSize(size: Int) = add(SizeTag.assemble(size)) + +fun TagArrayBuilder.dimension(dim: DimensionTag) = add(DimensionTag.assemble(dim)) + +fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) + +fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) + +fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) + +fun TagArrayBuilder.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri)) + +fun TagArrayBuilder.image(imageUrl: String) = add(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.thumb(trumbUrl: String) = add(ThumbTag.assemble(trumbUrl)) + +fun TagArrayBuilder.summary(summary: String) = add(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.fallback(fallbackUrl: String) = add(FallbackTag.assemble(fallbackUrl)) + +fun TagArrayBuilder.service(service: String) = add(ServiceTag.assemble(service)) + +fun TagArrayBuilder.fromEvent( + event: HexKey, + relayHint: String?, +) = add(ETag.assemble(event, relayHint, null)) 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 90a24d87c0..23792c2cbd 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,10 +21,17 @@ package com.vitorpamplona.quartz.experimental.relationshipStatus import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +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 +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -36,48 +43,42 @@ class RelationshipStatusEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun rank() = tags.firstNotNullOfOrNull(RankTag::parse) + + fun petname() = tags.firstNotNullOfOrNull(PetnameTag::parse) + + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + companion object { const val KIND = 30382 const val ALT = "Relationship Status" - const val PETNAME = "petname" - const val SUMMARY = "summary" - - private fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (RelationshipStatusEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTagSerializer.toTagArray(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - fun create( targetUser: HexKey, petname: String? = null, summary: String? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), + publicInitializer: TagArrayBuilder.() -> Unit = {}, + privateInitializer: TagArrayBuilder.() -> Unit = {}, onReady: (RelationshipStatusEvent) -> Unit, ) { - val tags = mutableListOf>() - tags.add(arrayOf("d", targetUser)) - tags.add(AltTagSerializer.toTagArray(ALT)) + val publicTags = + tagArray { + alt(ALT) + dTag(targetUser) + publicInitializer() + } - val privateTags = mutableListOf>() - petname?.let { privateTags.add(arrayOf(PETNAME, it)) } - summary?.let { privateTags.add(arrayOf(SUMMARY, it)) } + val privateTags = + tagArray { + petname?.let { petname(it) } + summary?.let { summary(it) } + privateInitializer() + } - encryptTags(privateTags.toTypedArray(), signer) { content -> - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + PrivateTagsInContent.encryptNip44(privateTags, signer) { content -> + signer.sign(createdAt, KIND, publicTags, content, onReady) } } } 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 new file mode 100644 index 0000000000..3d371ef77d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/TagArrayBuilderExt.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.relationshipStatus + +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.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 new file mode 100644 index 0000000000..df1ccfbfcf --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetnameTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.relationshipStatus.tags + +class PetnameTag { + companion object { + const val TAG_NAME = "petname" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(petname: String) = arrayOf(TAG_NAME, petname) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/RankTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/RankTag.kt new file mode 100644 index 0000000000..3525445d0b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/RankTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.relationshipStatus.tags + +class RankTag { + companion object { + const val TAG_NAME = "rank" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Int? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toIntOrNull() + } + + @JvmStatic + fun assemble(rank: Int) = arrayOf(TAG_NAME, rank.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/SummaryTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/SummaryTag.kt new file mode 100644 index 0000000000..8a7696c295 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/SummaryTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.relationshipStatus.tags + +class SummaryTag { + companion object { + const val TAG_NAME = "summary" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(ip: String) = arrayOf(TAG_NAME, ip) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt index 63fe66daf2..d0d2279308 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt @@ -21,132 +21,54 @@ package com.vitorpamplona.quartz.experimental.zapPolls import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments +import com.vitorpamplona.quartz.experimental.zapPolls.tags.ClosedAtTag +import com.vitorpamplona.quartz.experimental.zapPolls.tags.ConsensusThresholdTag +import com.vitorpamplona.quartz.experimental.zapPolls.tags.MaximumTag +import com.vitorpamplona.quartz.experimental.zapPolls.tags.MinimumTag +import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils -const val POLL_OPTION = "poll_option" -const val VALUE_MAXIMUM = "value_maximum" -const val VALUE_MINIMUM = "value_minimum" -const val CONSENSUS_THRESHOLD = "consensus_threshold" -const val CLOSED_AT = "closed_at" - @Immutable class PollNoteEvent( id: HexKey, pubKey: HexKey, createdAt: Long, tags: Array>, - // ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps content: String, sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun pollOptions() = tags.filter { it.size > 2 && it[0] == POLL_OPTION }.associate { it[1].toInt() to it[2] } +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun pollOptionsArray() = tags.mapNotNull(PollOptionTag::parse) - fun minimumAmount() = tags.firstOrNull { it.size > 1 && it[0] == VALUE_MINIMUM }?.getOrNull(1)?.toLongOrNull() + fun pollOptions() = pollOptionsArray().associate { it.index to it.descriptor } - fun maximumAmount() = tags.firstOrNull { it.size > 1 && it[0] == VALUE_MAXIMUM }?.getOrNull(1)?.toLongOrNull() + fun minAmount() = tags.firstNotNullOfOrNull(MinimumTag::parse) - fun getTagLong(property: String): Long? { - val number = tags.firstOrNull { it.size > 1 && it[0] == property }?.get(1) + fun maxAmount() = tags.firstNotNullOfOrNull(MaximumTag::parse) - return if (number.isNullOrBlank() || number == "null") { - null - } else { - number.toLong() - } - } + fun closedAt() = tags.firstNotNullOfOrNull(ClosedAtTag::parse) + + fun consensusThreshold() = tags.firstNotNullOfOrNull(ConsensusThresholdTag::parse) companion object { const val KIND = 6969 - const val ALT = "Poll event" + const val ALT_DESCRIPTION = "Poll event" - fun create( - msg: String, - replyTos: List?, - mentions: List?, - addresses: List?, - signer: NostrSigner, + fun build( + post: String, + options: List, createdAt: Long = TimeUtils.now(), - pollOptions: Map, - valueMaximum: Int?, - valueMinimum: Int?, - consensusThreshold: Int?, - closedAt: Int?, - zapReceiver: List? = null, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - isDraft: Boolean, - onReady: (PollNoteEvent) -> Unit, - ) { - val tags = mutableListOf>() - replyTos?.forEach { tags.add(arrayOf("e", it)) } - mentions?.forEach { tags.add(arrayOf("p", it)) } - addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } - pollOptions.forEach { poll_op -> - tags.add(arrayOf(POLL_OPTION, poll_op.key.toString(), poll_op.value)) - } - valueMaximum?.let { tags.add(arrayOf(VALUE_MAXIMUM, valueMaximum.toString())) } - valueMinimum?.let { tags.add(arrayOf(VALUE_MINIMUM, valueMinimum.toString())) } - consensusThreshold?.let { - tags.add(arrayOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) - } - closedAt?.let { tags.add(arrayOf(CLOSED_AT, closedAt.toString())) } - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) - } - geohash?.let { tags.addAll(geohashMipMap(it)) } - imetas?.forEach { - tags.add(Nip92MediaAttachments.createTag(it)) - } - emojis?.forEach { tags.add(it.toTagArray()) } - tags.add(AltTagSerializer.toTagArray(ALT)) - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), msg, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) - } + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val tags = TagArrayBuilder() + tags.pollOptions(options) + tags.alt(ALT_DESCRIPTION) + tags.apply(initializer) + return EventTemplate(createdAt, KIND, tags.build(), post) } } } - -/* -{ - "id": <32-bytes lowercase hex-encoded sha256 of the serialized event data> - "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, - "created_at": , - "kind": 6969, - "tags": [ - ["e", <32-bytes hex of the id of the poll event>, ], - ["p", <32-bytes hex of the key>, ], - ["poll_option", "0", "poll option 0 description string"], - ["poll_option", "1", "poll option 1 description string"], - ["poll_option", "n", "poll option description string"], - ["value_maximum", "maximum satoshi value for inclusion in tally"], - ["value_minimum", "minimum satoshi value for inclusion in tally"], - ["consensus_threshold", "required percentage to attain consensus <0..100>"], - ["closed_at", "unix timestamp in seconds"], - ], - "ots": - "content": , - "sig": <64-bytes hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field> -} - */ diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..37b784d2af --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.zapPolls + +import com.vitorpamplona.quartz.experimental.zapPolls.tags.ClosedAtTag +import com.vitorpamplona.quartz.experimental.zapPolls.tags.ConsensusThresholdTag +import com.vitorpamplona.quartz.experimental.zapPolls.tags.MaximumTag +import com.vitorpamplona.quartz.experimental.zapPolls.tags.MinimumTag +import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.consensusThreshold(percentage: Double) = addUnique(ConsensusThresholdTag.assemble(percentage)) + +fun TagArrayBuilder.minAmount(value: Long) = addUnique(MinimumTag.assemble(value)) + +fun TagArrayBuilder.maxAmount(value: Long) = addUnique(MaximumTag.assemble(value)) + +fun TagArrayBuilder.closedAt(timestamp: Long) = addUnique(ClosedAtTag.assemble(timestamp)) + +fun TagArrayBuilder.pollOption( + index: Int, + description: String, +) = add(PollOptionTag.assemble(index, description)) + +fun TagArrayBuilder.pollOptions(options: Map) = addAll(options.map { PollOptionTag.assemble(it.key, it.value) }) + +fun TagArrayBuilder.pollOptions(options: List) = addAll(options.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ClosedAtTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ClosedAtTag.kt new file mode 100644 index 0000000000..5b07019a84 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ClosedAtTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.zapPolls.tags + +class ClosedAtTag { + companion object { + const val TAG_NAME = "closed_at" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Long? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ConsensusThresholdTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ConsensusThresholdTag.kt new file mode 100644 index 0000000000..87e3967ec9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ConsensusThresholdTag.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.zapPolls.tags + +import kotlin.math.round + +class ConsensusThresholdTag { + companion object { + const val TAG_NAME = "consensus_threshold" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Double? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toLongOrNull()?.toDouble()?.div(100) + } + + @JvmStatic + fun assemble(percentage: Double) = arrayOf(TAG_NAME, (round(percentage * 100)).toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MaximumTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MaximumTag.kt new file mode 100644 index 0000000000..bf6381dee6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MaximumTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.zapPolls.tags + +class MaximumTag { + companion object { + const val TAG_NAME = "value_maximum" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Long? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MinimumTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MinimumTag.kt new file mode 100644 index 0000000000..7f1b8cfe84 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MinimumTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.zapPolls.tags + +class MinimumTag { + companion object { + const val TAG_NAME = "value_minimum" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Long? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/PollOptionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/PollOptionTag.kt new file mode 100644 index 0000000000..f5a74b06da --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/PollOptionTag.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.zapPolls.tags + +class PollOptionTag( + val index: Int, + val descriptor: String, +) { + fun toTagArray() = assemble(index, descriptor) + + companion object { + const val TAG_NAME = "poll_option" + const val TAG_SIZE = 3 + + @JvmStatic + fun parse(tag: Array): PollOptionTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + + val index = tag[1].toIntOrNull() ?: return null + + return PollOptionTag(index, tag[2]) + } + + @JvmStatic + fun assemble( + index: Int, + descriptor: String, + ) = arrayOf(TAG_NAME, index.toString(), descriptor) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt index e77cacf121..052f2b9ba2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt @@ -21,25 +21,26 @@ package com.vitorpamplona.quartz.nip01Core import android.util.Log -import com.vitorpamplona.quartz.CryptoUtils import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 import com.vitorpamplona.quartz.utils.Hex fun Event.generateId(): String = EventHasher.hashId(pubKey, createdAt, kind, tags, content) -fun Event.hasCorrectIDHash(): Boolean { +fun Event.verifyId(): Boolean { if (id.isEmpty()) return false return id == generateId() } -fun Event.hasVerifiedSignature(): Boolean { +fun Event.verifySignature(): Boolean { if (id.isEmpty() || sig.isEmpty()) return false - return CryptoUtils.verifySignature(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) + return Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) } /** Checks if the ID is correct and then if the pubKey's secret key signed the event. */ fun Event.checkSignature() { - if (!hasCorrectIDHash()) { + if (!verifyId()) { throw Exception( """ |Unexpected ID. @@ -49,14 +50,14 @@ fun Event.checkSignature() { """.trimIndent(), ) } - if (!hasVerifiedSignature()) { + if (!verifySignature()) { throw Exception("""Bad signature!""") } } -fun Event.hasValidSignature(): Boolean = +fun Event.verify(): Boolean = try { - hasCorrectIDHash() && hasVerifiedSignature() + verifyId() && verifySignature() } catch (e: Exception) { Log.w("Event", "Event $id does not have a valid signature: ${toJson()}", e) false diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/MetadataEvent.kt deleted file mode 100644 index f62f9cc68c..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/MetadataEvent.kt +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip01Core - -import android.util.Log -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ObjectNode -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip39ExtIdentities.updateClaims -import com.vitorpamplona.quartz.utils.TimeUtils -import java.io.ByteArrayInputStream -import java.io.StringWriter - -class MetadataEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun contactMetaData() = - try { - EventMapper.mapper.readValue(content, UserMetadata::class.java) - } catch (e: Exception) { - // e.printStackTrace() - Log.w("MetadataEvent", "Content Parse Error: ${toNostrUri()} ${e.localizedMessage}") - null - } - - companion object { - const val KIND = 0 - - fun newUser( - name: String?, - signer: NostrSignerSync, - createdAt: Long = TimeUtils.now(), - ): MetadataEvent? { - // Tries to not delete any existing attribute that we do not work with. - val currentJson = ObjectMapper().createObjectNode() - - name?.let { addIfNotBlank(currentJson, "name", it.trim()) } - val writer = StringWriter() - ObjectMapper().writeValue(writer, currentJson) - - val tags = mutableListOf>() - - tags.add( - AltTagSerializer.toTagArray("User profile for ${name ?: currentJson.get("name").asText() ?: ""}"), - ) - - return signer.sign(createdAt, KIND, tags.toTypedArray(), writer.buffer.toString()) - } - - fun updateFromPast( - latest: MetadataEvent?, - name: String?, - picture: String?, - banner: String?, - website: String?, - about: String?, - nip05: String?, - lnAddress: String?, - lnURL: String?, - pronouns: String?, - twitter: String?, - mastodon: String?, - github: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MetadataEvent) -> Unit, - ) { - // Tries to not delete any existing attribute that we do not work with. - val currentJson = - if (latest != null) { - ObjectMapper() - .readTree( - ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8)), - ) as ObjectNode - } else { - ObjectMapper().createObjectNode() - } - - name?.let { addIfNotBlank(currentJson, "name", it.trim()) } - name?.let { addIfNotBlank(currentJson, "display_name", it.trim()) } - picture?.let { addIfNotBlank(currentJson, "picture", it.trim()) } - banner?.let { addIfNotBlank(currentJson, "banner", it.trim()) } - website?.let { addIfNotBlank(currentJson, "website", it.trim()) } - pronouns?.let { addIfNotBlank(currentJson, "pronouns", it.trim()) } - about?.let { addIfNotBlank(currentJson, "about", it.trim()) } - nip05?.let { addIfNotBlank(currentJson, "nip05", it.trim()) } - lnAddress?.let { addIfNotBlank(currentJson, "lud16", it.trim()) } - lnURL?.let { addIfNotBlank(currentJson, "lud06", it.trim()) } - - val writer = StringWriter() - ObjectMapper().writeValue(writer, currentJson) - - val tags = mutableListOf>() - tags.add(AltTagSerializer.toTagArray("User profile for ${name ?: currentJson.get("name").asText() ?: ""}")) - - latest?.updateClaims(twitter, github, mastodon)?.forEach { - tags.add(it) - } - - signer.sign(createdAt, KIND, tags.toTypedArray(), writer.buffer.toString(), onReady) - } - - private fun addIfNotBlank( - currentJson: ObjectNode, - key: String, - value: String, - ) { - if (value.isBlank() || value == "null") { - currentJson.remove(key) - } else { - currentJson.put(key, value.trim()) - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt index c0d1ab8b23..d57e5280ec 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt @@ -22,12 +22,15 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @Immutable -interface AddressableEvent { +interface AddressableEvent : IEvent { fun dTag(): String - fun address(relayHint: String? = null): ATag + fun aTag(relayHint: String? = null): ATag + + fun address(): Address fun addressTag(): String } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt index dd805b2c37..4cac7071f1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag @Immutable @@ -38,10 +38,12 @@ open class BaseAddressableEvent( AddressableEvent { override fun dTag() = tags.dTag() - override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + + override fun address() = Address(kind, pubKey, dTag()) /** * Creates the tag in a memory efficient way (without creating the ATag class */ - override fun addressTag() = ATag.assembleATagId(kind, pubKey, dTag()) + override fun addressTag() = Address.assemble(kind, pubKey, dTag()) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt index 0968f210e4..24404b5a2e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @Immutable open class BaseReplaceableEvent( @@ -36,12 +36,14 @@ open class BaseReplaceableEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { override fun dTag() = FIXED_D_TAG - override fun address(relayHint: String?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint) + override fun aTag(relayHint: String?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint) + + override fun address() = Address(kind, pubKey, dTag()) /** * Creates the tag in a memory efficient way (without creating the ATag class */ - override fun addressTag() = ATag.assembleATagId(kind, pubKey, FIXED_D_TAG) + override fun addressTag() = Address.assemble(kind, pubKey, dTag()) companion object { const val FIXED_D_TAG = "" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt index 60920971de..7c536a4e6f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt @@ -22,10 +22,9 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable import com.fasterxml.jackson.annotation.JsonProperty -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.EventManualSerializer import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -39,7 +38,7 @@ open class Event( val tags: TagArray, val content: String, val sig: HexKey, -) { +) : IEvent { open fun isContentEncoded() = false open fun countMemory(): Long = @@ -53,16 +52,19 @@ open class Event( fun toJson(): String = EventManualSerializer.toJson(id, pubKey, createdAt, kind, tags, content, sig) + /** + * For debug purposes only + */ + fun toPrettyJson(): String = EventManualSerializer.toPrettyJson(id, pubKey, createdAt, kind, tags, content, sig) + companion object { fun fromJson(json: String): Event = EventMapper.fromJson(json) - fun create( - signer: NostrSigner, + fun build( kind: Int, - tags: Array> = emptyArray(), content: String = "", createdAt: Long = TimeUtils.now(), - onReady: (Event) -> Unit, - ) = signer.sign(createdAt, kind, tags, content, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(kind, content, createdAt, initializer) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/HexKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt similarity index 92% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/HexKey.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index b729491b32..1330757784 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/HexKey.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core +package com.vitorpamplona.quartz.nip01Core.core import com.vitorpamplona.quartz.utils.Hex @@ -28,3 +28,7 @@ typealias HexKey = String fun ByteArray.toHexKey(): HexKey = Hex.encode(this) fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this) + +const val PUBKEY_LENGTH = 64 + +const val EVENT_ID_LENGTH = 64 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/Price.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/IEvent.kt similarity index 88% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/Price.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/IEvent.kt index 1c899002ac..66d87fc14c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/Price.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/IEvent.kt @@ -18,10 +18,6 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip99Classifieds +package com.vitorpamplona.quartz.nip01Core.core -data class Price( - val amount: String, - val currency: String?, - val frequency: String?, -) +interface IEvent diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Tag.kt new file mode 100644 index 0000000000..d97acc9c1c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Tag.kt @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.core + +typealias Tag = Array + +/** + * Returns if the Tag has at least $index elements. + */ +fun Tag.has(index: Int) = size > index + +/** + * Returns if the Tag has less than $index elements. + */ +fun Tag.lacks(index: Int) = size <= index + +fun Tag.name() = this[0] + +fun Tag.value() = this[1] + +fun Tag.hasValue() = this[1].isNotEmpty() + +fun Tag.nameOrNull() = if (size > 0) name() else null + +fun Tag.valueOrNull() = if (size > 1) value() else null + +fun Tag.isNameUnsafe(name: String): Boolean = name() == name + +fun Tag.isValueUnsafe(value: String): Boolean = value() == value + +fun Tag.isValueInUnsafe(values: Set): Boolean = value() in values + +fun Tag.match(name: String): Boolean = if (size > 0) isNameUnsafe(name) else false + +fun Tag.isValue(value: String): Boolean = if (size > 1) isValueUnsafe(value) else false + +fun Tag.match( + name: String, + value: String, + minSize: Int, +): Boolean = if (size >= minSize) isNameUnsafe(name) && isValueUnsafe(value) else false + +fun Tag.match( + name: String, + values: Set, + minSize: Int, +): Boolean = if (size >= minSize) isNameUnsafe(name) && isValueInUnsafe(values) else false + +fun Tag.match( + name: String, + minSize: Int, +): Boolean = if (size >= minSize) isNameUnsafe(name) else false + +fun Tag.isNotName( + name: String, + minSize: Int, +): Boolean = !match(name, minSize) + +fun Tag.matchAndHasValue( + name: String, + minSize: Int, +): Boolean = if (size >= minSize) isNameUnsafe(name) && hasValue() else false + +fun Tag.valueIfMatches( + name: String, + minSize: Int, +): String? = if (match(name, minSize)) value() else null + +fun Tag.valueToIntIfMatches( + name: String, + minSize: Int, +): Int? = if (match(name, minSize)) value().toIntOrNull() else null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt index aa665d136e..36ba47ae64 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.core -import com.vitorpamplona.quartz.nip01Core.HexKey - typealias TagArray = Array> +fun TagArray.builder(initializer: TagArrayBuilder.() -> Unit = {}) = TagArrayBuilder().addAll(this).apply(initializer).build() + /** * Performs the given [action] on each tag that matches the given [tagName]. */ @@ -109,7 +109,7 @@ fun TagArray.mapValues(tagName: String) = * Returns the first non-null value produced by [transform] function being applied to all tags * that match the [tagName] */ -fun TagArray.firstMapTagged( +fun TagArray.firstMappedTag( tagName: String, transform: (tagValue: Array) -> R, ) = this.firstNotNullOfOrNull { @@ -161,7 +161,8 @@ fun TagArray.firstTagValueFor(vararg tagNames: String) = this.firstOrNull { it.s fun TagArray.isTagged( tagName: String, tagValue: String, -) = this.any { it.size > 1 && it[0] == tagName && it[1] == tagValue } + ignoreCase: Boolean = false, +) = this.any { it.size > 1 && it[0] == tagName && it[1].equals(tagValue, ignoreCase) } /** * Returns `true` if at least one tag matches the given [tagName] and is in [tagValues] @@ -171,6 +172,51 @@ fun TagArray.isAnyTagged( tagValues: Set, ) = this.any { it.size > 1 && it[0] == tagName && it[1] in tagValues } +fun TagArray.any( + predicate: (Array, U) -> Boolean, + extras: U, +): Boolean { + for (element in this) if (predicate(element, extras)) return true + return false +} + +public inline fun Array.firstNotNullOfOrNull( + transform: (T, U) -> R?, + extras: U, +): R? { + for (element in this) { + val result = transform(element, extras) + if (result != null) { + return result + } + } + return null +} + +/** + * Returns `true` if at least one tag matches the given [tagName] and is in [tagValues] + */ +fun TagArray.firstAnyLowercaseTaggedValue( + tagName: String, + tagValues: Set, +) = this.firstOrNull { it.size > 1 && it[0] == tagName && it[1].lowercase() in tagValues }?.getOrNull(1) + +/** + * Returns `true` if at least one tag matches the given [tagName] and is in [tagValues] + */ +fun TagArray.isAnyLowercaseTagged( + tagName: String, + tagValues: Set, +) = this.any { it.size > 1 && it[0] == tagName && it[1].lowercase() in tagValues } + +/** + * Returns `true` if at least one tag matches the given [tagName] and is in [tagValues] + */ +fun TagArray.firstAnyTaggedValue( + tagName: String, + tagValues: Set, +) = this.firstOrNull { it.size > 1 && it[0] == tagName && it[1] in tagValues }?.getOrNull(1) + /** * Returns `true` if at least one tag has value that contains [text] */ @@ -178,3 +224,15 @@ fun TagArray.tagValueContains( text: String, ignoreCase: Boolean = false, ) = this.any { it.size > 1 && it[1].contains(text, ignoreCase) } + +fun TagArray.containsAllTagNamesWithValues(names: Set): Boolean { + val remaining = names.toMutableSet() + + this.forEach { + if (it.size > 1) { + remaining.remove(it[0]) + } + } + + return remaining.isEmpty() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index 1c0188f812..3d03f667bf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -20,23 +20,75 @@ */ package com.vitorpamplona.quartz.nip01Core.core -class TagArrayBuilder { - val tagList = mutableListOf>() +class TagArrayBuilder { + /** + * keeps a tag list by tag names to treat tags that must be unique + */ + private val tagList = mutableMapOf>() - fun add(tag: Array): TagArrayBuilder { - tagList.add(tag) + fun remove(tagName: String): TagArrayBuilder { + tagList.remove(tagName) return this } - fun addAll(tag: List>): TagArrayBuilder { - tagList.addAll(tag) + fun remove( + tagName: String, + tagValue: String, + ): TagArrayBuilder { + tagList[tagName]?.removeIf { it.value() == tagValue } + if (tagList[tagName]?.isEmpty() == true) { + tagList.remove(tagName) + } return this } - fun addAll(tag: Array>): TagArrayBuilder { - tagList.addAll(tag) + fun removeIf( + predicate: (Tag, Tag) -> Boolean, + toCompare: Tag, + ): TagArrayBuilder { + tagList[toCompare.name()]?.removeIf { predicate(it, toCompare) } + if (tagList[toCompare.name()]?.isEmpty() == true) { + tagList.remove(toCompare.name()) + } return this } - fun build() = tagList.toTypedArray() + fun removeIf( + tagName: String, + tagValue: String, + ): TagArrayBuilder { + tagList[tagName]?.removeIf { it.value() == tagValue } + if (tagList[tagName]?.isEmpty() == true) { + tagList.remove(tagName) + } + return this + } + + fun add(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList.getOrPut(tag[0], ::mutableListOf).add(tag) + return this + } + + fun addUnique(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList[tag[0]] = mutableListOf(tag) + return this + } + + fun addAll(tag: List>): TagArrayBuilder { + tag.forEach(::add) + return this + } + + fun addAll(tag: Array>): TagArrayBuilder { + tag.forEach(::add) + return this + } + + fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray() + + fun build() = toTypedArray() } + +inline fun tagArray(initializer: TagArrayBuilder.() -> Unit = {}): TagArray = TagArrayBuilder().apply(initializer).build() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt new file mode 100644 index 0000000000..495d26c417 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.crypto + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate + +class DeterministicSigner( + val key: KeyPair, + val pubKey: HexKey = key.pubKey.toHexKey(), +) { + fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, key.privKey!!, nonce = null) + + fun sign(ev: EventTemplate): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content) +} 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 new file mode 100644 index 0000000000..4ed4133d83 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.crypto + +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.RandomInstance + +class EventAssembler { + companion object { + fun hashAndSign( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + privKey: ByteArray, + nonce: ByteArray? = RandomInstance.bytes(32), + ): T { + val id = EventHasher.hashIdBytes(pubKey, createdAt, kind, tags, content) + val sig = Nip01.sign(id, privKey, nonce).toHexKey() + + return EventFactory.create( + id.toHexKey(), + pubKey, + createdAt, + kind, + tags, + content, + sig, + ) as T + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventHasher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt similarity index 59% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventHasher.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt index 13a6829506..591b2695b4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventHasher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt @@ -18,42 +18,50 @@ * AN ACTION OF CONTRACT, TORT OR 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 +package com.vitorpamplona.quartz.nip01Core.crypto +import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.utils.sha256Hash +import com.vitorpamplona.quartz.utils.sha256.sha256 class EventHasher { companion object { + fun makeJsonObjectForId( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): ArrayNode { + val factory = JsonNodeFactory.instance + return factory.arrayNode(6).apply { + add(0) + add(pubKey) + add(createdAt) + add(kind) + add( + factory.arrayNode(tags.size).apply { + tags.forEach { tag -> + add( + factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, + ) + } + }, + ) + add(content) + } + } + fun makeJsonForId( pubKey: HexKey, createdAt: Long, kind: Int, tags: Array>, content: String, - ): String { - val factory = JsonNodeFactory.instance - val rawEvent = - factory.arrayNode(6).apply { - add(0) - add(pubKey) - add(createdAt) - add(kind) - add( - factory.arrayNode(tags.size).apply { - tags.forEach { tag -> - add( - factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, - ) - } - }, - ) - add(content) - } - - return EventMapper.toJson(rawEvent) - } + ): String = EventMapper.toJson(makeJsonObjectForId(pubKey, createdAt, kind, tags, content)) fun hashIdBytes( pubKey: HexKey, @@ -61,7 +69,9 @@ class EventHasher { kind: Int, tags: Array>, content: String, - ): ByteArray = sha256Hash(makeJsonForId(pubKey, createdAt, kind, tags, content).toByteArray()) + ): ByteArray = sha256(makeJsonForId(pubKey, createdAt, kind, tags, content).toByteArray()) + + fun hashId(serializedJsonAsBytes: ByteArray): String = sha256(serializedJsonAsBytes).toHexKey() fun hashId( pubKey: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/KeyPair.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt similarity index 80% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/KeyPair.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt index f90e576073..048309e560 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/KeyPair.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt @@ -18,14 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core +package com.vitorpamplona.quartz.nip01Core.crypto -import com.vitorpamplona.quartz.CryptoUtils +import com.vitorpamplona.quartz.nip01Core.core.toHexKey class KeyPair( privKey: ByteArray? = null, pubKey: ByteArray? = null, - forcePubKeyCheck: Boolean = true, ) { val privKey: ByteArray? val pubKey: ByteArray @@ -34,8 +33,8 @@ class KeyPair( if (privKey == null) { if (pubKey == null) { // create new, random keys - this.privKey = CryptoUtils.privkeyCreate() - this.pubKey = CryptoUtils.pubkeyCreate(this.privKey) + this.privKey = Nip01.privKeyCreate() + this.pubKey = Nip01.pubKeyCreate(this.privKey) } else { // this is a read-only account check(pubKey.size == 32) @@ -45,12 +44,7 @@ class KeyPair( } else { // as private key is provided, ignore the public key and set keys according to private key this.privKey = privKey - - if (pubKey == null || forcePubKeyCheck) { - this.pubKey = CryptoUtils.pubkeyCreate(privKey) - } else { - this.pubKey = pubKey - } + this.pubKey = Nip01.pubKeyCreate(privKey) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt new file mode 100644 index 0000000000..d0cb443401 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.crypto + +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.Secp256k1Instance + +object Nip01 { + fun privKeyCreate() = RandomInstance.bytes(32) + + fun pubKeyCreate(privKey: ByteArray) = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33) + + fun sign( + data: ByteArray, + privKey: ByteArray, + nonce: ByteArray? = RandomInstance.bytes(32), + ): ByteArray = Secp256k1Instance.signSchnorr(data, privKey, nonce) + + fun verify( + signature: ByteArray, + hash: ByteArray, + pubKey: ByteArray, + ): Boolean = Secp256k1Instance.verifySchnorr(signature, hash, pubKey) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/experimental/Nip01Serializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/experimental/Nip01Serializer.kt deleted file mode 100644 index e4b89fbc0c..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/experimental/Nip01Serializer.kt +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip01Core.experimental - -import com.vitorpamplona.quartz.nip01Core.core.Event -import java.nio.ByteBuffer -import java.nio.CharBuffer -import java.nio.charset.CodingErrorAction -import java.security.MessageDigest - -class Nip01Serializer { - interface Writer { - fun append( - value: ByteArray, - offset: Int, - length: Int, - ) - - fun append( - value: String, - offset: Int, - length: Int, - ) - - fun append(value: ByteArray) - - fun append(value: Byte) - - fun dump() - } - - class BufferedDigestWriter( - val digest: MessageDigest, - ) : Writer { - val utf8Encoder = - Charsets.UTF_8 - .newEncoder() - .onMalformedInput(CodingErrorAction.IGNORE) - .onUnmappableCharacter(CodingErrorAction.IGNORE) - - companion object { - const val BUFFER_SIZE = 128 - } - - private val innerBuffer = ByteArray(BUFFER_SIZE) - private val byteBuffer = ByteBuffer.wrap(innerBuffer) - - override fun append(value: Byte) { - if (byteBuffer.position() == byteBuffer.capacity()) { - dump() - } - - byteBuffer.put(value) - } - - override fun append(value: ByteArray) { - if (value.size >= BUFFER_SIZE) { - dump() - // don't cache if the cache is smaller than the value - digest.update(value) - } else { - if (value.size > byteBuffer.remaining()) { - dump() - } - - value.copyInto(innerBuffer, byteBuffer.position(), 0, value.size) - byteBuffer.position(byteBuffer.position() + value.size) - } - } - - override fun append( - value: ByteArray, - offset: Int, - length: Int, - ) { - if (value.size >= BUFFER_SIZE) { - dump() - // don't cache if the cache is smaller than the value - digest.update(value, offset, length) - } else { - if (length > byteBuffer.remaining()) { - dump() - } - - value.copyInto(innerBuffer, byteBuffer.position(), offset, offset + length) - byteBuffer.position(byteBuffer.position() + value.size) - } - } - - override fun append( - value: String, - offset: Int, - length: Int, - ) { - val toEncode = CharBuffer.wrap(value, offset, offset + length) - while (toEncode.hasRemaining()) { - val result = utf8Encoder.encode(toEncode, byteBuffer, false) - if (result.isOverflow) { - dump() - } - } - } - - override fun dump() { - if (byteBuffer.position() > 0) { - digest.update(innerBuffer, 0, byteBuffer.position()) - byteBuffer.clear() - } - } - } - - class StringWriter : Writer { - private val stringBuilder: StringBuilder = StringBuilder() - - override fun append( - value: ByteArray, - offset: Int, - length: Int, - ) { - stringBuilder.append(value.decodeToString(offset, offset + length)) - } - - override fun append(value: ByteArray) { - stringBuilder.append(value.decodeToString()) - } - - override fun append( - value: String, - offset: Int, - length: Int, - ) { - stringBuilder.append(value, offset, offset + length) - } - - override fun append(value: Byte) { - stringBuilder.append(value.toInt().toChar()) - } - - override fun toString(): String = stringBuilder.toString() - - override fun dump() { - } - } - - companion object { - private const val DOUBLE_QUOTE_ASCII = 0x22 - private const val BACKLASH_ASCII = 0x5C - private const val TAB_ASCII = 0x09 - private const val BACKSPACE_ASCII = 0x08 - private const val NEWLINE_ASCII = 0x0A - private const val RETURN_ASCII = 0x0D - private const val FORM_FEED_ASCII = 0x0C - - private val ESCAPED_DOUBLE_QUOTE = "\\\"".toByteArray() - private val ESCAPED_DOUBLE_BACKLASH = "\\\\".toByteArray() - private val ESCAPED_TAB = "\\t".toByteArray() - private val ESCAPED_BACKSPACE = "\\b".toByteArray() - private val ESCAPED_NEW_LINE = "\\n".toByteArray() - private val ESCAPED_RETURN = "\\r".toByteArray() - private val ESCAPED_FORM_FEED = "\\f".toByteArray() - - val ARRAY_ZERO_COMMA_QUOTE = "[0,\"".toByteArray() - val COMMA = ",".toByteArray() - val QUOTE = "\"".toByteArray() - val QUOTE_COMMA = "\",".toByteArray() - val QUOTE_COMMA_QUOTE = "\",\"".toByteArray() - val COMMA_OPEN_ARRAY = ",[".toByteArray() - val COMMA_OPEN_ARRAY_QUOTE = ",[\"".toByteArray() - val OPEN_ARRAY = "[".toByteArray() - val OPEN_ARRAY_QUOTE = "[\"".toByteArray() - val QUOTE_CLOSE_ARRAY = "\"]".toByteArray() - val CLOSE_ARRAY_COMMA_QUOTE = "],\"".toByteArray() - - private val MAPPER = Array(255) { null } - - init { - for (i in 0 until 0x1F) { - MAPPER[i] = String.format("\\u%04x", i.toByte()).toByteArray() - } - - MAPPER[DOUBLE_QUOTE_ASCII] = ESCAPED_DOUBLE_QUOTE - MAPPER[BACKLASH_ASCII] = ESCAPED_DOUBLE_BACKLASH - MAPPER[TAB_ASCII] = ESCAPED_TAB - MAPPER[BACKSPACE_ASCII] = ESCAPED_BACKSPACE - MAPPER[NEWLINE_ASCII] = ESCAPED_NEW_LINE - MAPPER[RETURN_ASCII] = ESCAPED_RETURN - MAPPER[FORM_FEED_ASCII] = ESCAPED_FORM_FEED - } - } - - fun escapeStringInto( - value: String, - writer: Writer, - ) { - var lastNormalSequenceStarts = 0 - var lastNormalSequenceLength = 0 - - for (i in value.indices) { - if (value[i].code >= 255) { - lastNormalSequenceLength++ - } else { - val escaped = MAPPER[value[i].code] - if (escaped != null) { - if (lastNormalSequenceLength > 0) { - writer.append(value, lastNormalSequenceStarts, lastNormalSequenceLength) - } - lastNormalSequenceStarts = i + 1 - lastNormalSequenceLength = 0 - writer.append(escaped) - } else { - lastNormalSequenceLength++ - } - } - } - - if (lastNormalSequenceLength > 0) { - if (lastNormalSequenceLength == value.length) { - writer.append(value, 0, value.length) - } else { - writer.append(value, lastNormalSequenceStarts, lastNormalSequenceLength) - } - } - } - - fun serializeEventInto( - event: Event, - writer: Writer, - ) { - writer.append(ARRAY_ZERO_COMMA_QUOTE) - writer.append(event.pubKey.toByteArray()) - writer.append(QUOTE_COMMA) - writer.append(event.createdAt.toString().toByteArray()) - writer.append(COMMA) - writer.append(event.kind.toString().toByteArray()) - writer.append(COMMA_OPEN_ARRAY) - - for (index in event.tags.indices) { - val tag = event.tags[index] - if (index > 0) { - writer.append(COMMA_OPEN_ARRAY_QUOTE) - } else { - writer.append(OPEN_ARRAY_QUOTE) - } - for (sIndex in tag.indices) { - if (sIndex > 0) { - writer.append(QUOTE_COMMA_QUOTE) - } - escapeStringInto(tag[sIndex], writer) - } - writer.append(QUOTE_CLOSE_ARRAY) - } - writer.append(CLOSE_ARRAY_COMMA_QUOTE) - - escapeStringInto(event.content, writer) - writer.append(QUOTE_CLOSE_ARRAY) - - writer.dump() - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventHintBundle.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt similarity index 65% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventHintBundle.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt index af8cda51ee..4f464f9e65 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventHintBundle.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt @@ -18,10 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core +package com.vitorpamplona.quartz.nip01Core.hints import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -31,9 +36,11 @@ data class EventHintBundle( val event: T, ) { var relay: String? = null + var authorHomeRelay: String? = null - constructor(event: T, relayHint: String? = null) : this(event) { + constructor(event: T, relayHint: String? = null, authorHomeRelay: String? = null) : this(event) { this.relay = relayHint + this.authorHomeRelay = authorHomeRelay } fun countMemory(): Long = @@ -43,9 +50,15 @@ data class EventHintBundle( fun toNEvent(): String = NEvent.create(event.id, event.pubKey, event.kind, relay) - fun toTagArray(tag: String) = listOfNotNull(tag, event.id, relay, event.pubKey).toTypedArray() + fun toETag() = ETag(event.id, relay, event.pubKey) - fun toETagArray() = toTagArray("e") + fun toATag() = ATag(event.kind, event.pubKey, event.dTag(), relay) - fun toQTagArray() = toTagArray("q") + fun toPTag() = PTag(event.pubKey, authorHomeRelay) + + fun toMarkedETag(marker: MarkedETag.MARKER) = MarkedETag(event.id, relay, marker, event.pubKey) + + fun toETagArray() = ETag.assemble(event.id, relay, event.pubKey) + + fun toQTagArray() = ETag(event.id, relay, event.pubKey).toQTagArray() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt new file mode 100644 index 0000000000..efee3a2be5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.hints.bloom.BloomFilterMurMur3 + +/** + * Instead of having one bloom filter per relay per type, which could create + * many large bloom filters for collections of very few items, this class uses + * only one mega bloom filter per type and uses the hashcode of the relay uri + * as seed differentiator in the hash function. + */ +class HintIndexer { + private val eventHints = BloomFilterMurMur3(10_000_000, 5) + private val addressHints = BloomFilterMurMur3(2_000_000, 5) + private val pubKeyHints = BloomFilterMurMur3(10_000_000, 5) + private val relayDB = mutableSetOf() + + private fun add( + id: ByteArray, + relay: String, + bloom: BloomFilterMurMur3, + ) { + relayDB.add(relay) + bloom.add(id, relay.hashCode()) + } + + private fun get( + id: ByteArray, + bloom: BloomFilterMurMur3, + ) = relayDB.filter { bloom.mightContain(id, it.hashCode()) } + + // -------------------- + // Event Host hints + // -------------------- + fun addEvent( + eventId: ByteArray, + relay: String, + ) = add(eventId, relay, eventHints) + + fun addEvent( + eventId: HexKey, + relay: String, + ) = addEvent(eventId.hexToByteArray(), relay) + + fun getEvent(eventId: ByteArray) = get(eventId, eventHints) + + fun getEvent(eventId: HexKey) = getEvent(eventId.hexToByteArray()) + + // -------------------- + // PubKeys Outbox hints + // -------------------- + fun addAddress( + addressId: ByteArray, + relay: String, + ) = add(addressId, relay, addressHints) + + fun addAddress( + addressId: String, + relay: String, + ) = addAddress(addressId.toByteArray(), relay) + + fun getAddress(addressId: ByteArray) = get(addressId, addressHints) + + fun getAddress(addressId: String) = getAddress(addressId.toByteArray()) + + // -------------------- + // PubKeys Outbox hints + // -------------------- + fun addKey( + key: ByteArray, + relay: String, + ) = add(key, relay, pubKeyHints) + + fun addKey( + key: HexKey, + relay: String, + ) = addKey(key.hexToByteArray(), relay) + + fun getKey(key: ByteArray) = get(key, pubKeyHints) + + fun getKey(key: HexKey) = getKey(key.hexToByteArray()) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt new file mode 100644 index 0000000000..aa94e3ca5c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints + +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint + +interface EventHintProvider { + fun eventHints(): List +} + +interface AddressHintProvider { + fun addressHints(): List +} + +interface PubKeyHintProvider { + fun pubKeyHints(): List +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BitSetExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BitSetExt.kt new file mode 100644 index 0000000000..be4ef3b55c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BitSetExt.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints.bloom + +import java.util.BitSet + +fun BitSet.printBits() = + buildString { + for (seed in 0 until size()) { + append(if (this@printBits.get(seed)) "1" else "0") + } + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BloomFilterMurMur3.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BloomFilterMurMur3.kt new file mode 100644 index 0000000000..e4e96b320a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BloomFilterMurMur3.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints.bloom + +import com.vitorpamplona.quartz.utils.RandomInstance +import java.util.Base64 +import java.util.BitSet +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +class BloomFilterMurMur3( + private val size: Int, + private val rounds: Int, + private val bits: BitSet = BitSet(size), + private val commonSalt: Int = RandomInstance.int(), +) { + private val hasher = MurmurHash3() + private val lock = ReentrantReadWriteLock() + + fun add( + value: ByteArray, + salt: Int = commonSalt, + ) { + lock.write { + repeat(rounds) { + bits.set(hash(value, salt + it)) + } + } + } + + fun mightContain( + value: ByteArray, + salt: Int = commonSalt, + ): Boolean { + lock.read { + repeat(rounds) { + if (!bits.get(hash(value, salt + it))) return false + } + return true + } + } + + private fun hash( + value: ByteArray, + seed: Int, + ) = hasher.hash(value, seed).mod(size) + + fun encode() = encode(this) + + fun printBits() = bits.printBits() + + companion object { + fun encode(f: BloomFilterMurMur3): String { + val bitSetB64 = Base64.getEncoder().encodeToString(f.bits.toByteArray()) + return "${f.size}:${f.rounds}:$bitSetB64:${f.commonSalt}" + } + + fun decode(encodedStr: String): BloomFilterMurMur3 { + val (sizeStr, roundsStr, filterB64, salt) = encodedStr.split(":") + val bitSet = BitSet.valueOf(Base64.getDecoder().decode(filterB64)) + return BloomFilterMurMur3(sizeStr.toInt(), roundsStr.toInt(), bitSet, salt.toInt()) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/MurmurHash3.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/MurmurHash3.kt new file mode 100644 index 0000000000..280ef14391 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/MurmurHash3.kt @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints.bloom + +class MurmurHash3 { + companion object { + const val ROUND_DOWN = 0xFFFFFFFC.toInt() + const val C1 = -0x3361d2af // 0xcc9e2d51 + const val C2 = 0x1b873593 + } + + /** + * Generates 32 bit hash . + * @param data the byte array to hash + * @param seed the seed for the hash (int) + * @return 32 bit hash of the given array + */ + fun hash( + data: ByteArray, + seed: Int, + ): Int { + var h1 = seed + val roundedEnd = data.size and ROUND_DOWN // Round down to 4-byte blocks + + var i = 0 + var k1 = 0 + while (i < roundedEnd) { + k1 = + ( + data[i++].toInt() and 0xFF or + (data[i++].toInt() and 0xFF shl 8) or + (data[i++].toInt() and 0xFF shl 16) or + (data[i++].toInt() and 0xFF shl 24) + ) * C1 + + h1 = h1 xor (((k1 shl 15) or (k1 ushr -15)) * C2) + h1 = ((h1 shl 13) or (h1 ushr -13)) * 5 + -0x19ab949c // 0xe6546b64 + } + + // processing tail (remaining bytes) + k1 = 0 + when (data.size and 3) { + 3 -> { + k1 = k1 or ((data[i + 2].toInt() and 0xFF) shl 16) + k1 = k1 or ((data[i + 1].toInt() and 0xFF) shl 8) + k1 = k1 or (data[i].toInt() and 0xFF) + + k1 *= C1 + k1 = (k1 shl 15) or (k1 ushr -15) + k1 *= C2 + + h1 = h1 xor k1 + } + + 2 -> { + k1 = k1 or (data[i + 1].toInt() and 0xFF shl 8) + k1 = k1 or (data[i].toInt() and 0xFF) + + k1 *= C1 + k1 = (k1 shl 15) or (k1 ushr -15) + k1 *= C2 + + h1 = h1 xor k1 + } + + 1 -> { + k1 = k1 or (data[i].toInt() and 0xFF) + + k1 *= C1 + k1 = (k1 shl 15) or (k1 ushr -15) + k1 *= C2 + + h1 = h1 xor k1 + } + } + + // final mix + h1 = h1 xor data.size + + // fmix32 + h1 = (h1 xor (h1 ushr 16)) * -0x7a143595 // 0x85ebca6b + h1 = (h1 xor (h1 ushr 13)) * -0x3d4d51cb // 0xc2b2ae35 + h1 = h1 xor (h1 ushr 16) + + return h1 + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt new file mode 100644 index 0000000000..e83191bc07 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints.types + +class AddressHint( + val addressId: String, + var relay: String? = null, +) : Hint { + override fun id() = addressId.toByteArray(Charsets.UTF_8) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt new file mode 100644 index 0000000000..80faa2149d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints.types + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray + +class EventIdHint( + val eventId: HexKey, + var relay: String? = null, +) : Hint { + override fun id() = eventId.hexToByteArray() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/Hint.kt similarity index 85% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningSerializer.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/Hint.kt index f04756dd10..591a3e6858 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/Hint.kt @@ -18,10 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip36SensitiveContent +package com.vitorpamplona.quartz.nip01Core.hints.types -class ContentWarningSerializer { - companion object { - fun toTagArray(reason: String = "") = arrayOf(CONTENT_WARNING, reason) - } +interface Hint { + fun id(): ByteArray } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt new file mode 100644 index 0000000000..431c121215 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints.types + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray + +class PubKeyHint( + val pubkey: HexKey, + var relay: String? = null, +) : Hint { + override fun id() = pubkey.hexToByteArray() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt index 528a3c5f1b..2d19402771 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt @@ -21,10 +21,42 @@ package com.vitorpamplona.quartz.nip01Core.jackson import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.fasterxml.jackson.databind.node.ObjectNode +import com.vitorpamplona.quartz.nip01Core.core.HexKey class EventManualSerializer { companion object { + private fun assemble( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: String, + ): ObjectNode { + val factory = JsonNodeFactory.instance + + return factory.objectNode().apply { + put("id", id) + put("pubkey", pubKey) + put("created_at", createdAt) + put("kind", kind) + replace( + "tags", + factory.arrayNode(tags.size).apply { + tags.forEach { tag -> + add( + factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, + ) + } + }, + ) + put("content", content) + put("sig", sig) + } + } + fun toJson( id: HexKey, pubKey: HexKey, @@ -34,29 +66,24 @@ class EventManualSerializer { content: String, sig: String, ): String { - val factory = JsonNodeFactory.instance - - val obj = - factory.objectNode().apply { - put("id", id) - put("pubkey", pubKey) - put("created_at", createdAt) - put("kind", kind) - replace( - "tags", - factory.arrayNode(tags.size).apply { - tags.forEach { tag -> - add( - factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, - ) - } - }, - ) - put("content", content) - put("sig", sig) - } - + val obj = assemble(id, pubKey, createdAt, kind, tags, content, sig) return EventMapper.mapper.writeValueAsString(obj) } + + /** + * For debug purposes only + */ + fun toPrettyJson( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: String, + ): String { + val obj = assemble(id, pubKey, createdAt, kind, tags, content, sig) + return EventMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt index fe6090dd7c..4cceec24d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt @@ -35,16 +35,19 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.RequestDeserializer import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip47WalletConnect.ResponseDeserializer -import com.vitorpamplona.quartz.nip59Giftwrap.Rumor -import com.vitorpamplona.quartz.nip59Giftwrap.RumorDeserializer -import com.vitorpamplona.quartz.nip59Giftwrap.RumorSerializer +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorDeserializer +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorSerializer class EventMapper { companion object { + val defaultPrettyPrinter = InliningTagArrayPrettyPrinter() + val mapper = jacksonObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + .setDefaultPrettyPrinter(defaultPrettyPrinter) .registerModule( SimpleModule() .addSerializer(Event::class.java, EventSerializer()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinter.kt new file mode 100644 index 0000000000..97c34ebfcf --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinter.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.util.DefaultIndenter +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter +import com.fasterxml.jackson.core.util.Separators + +class InliningTagArrayPrettyPrinter : DefaultPrettyPrinter { + companion object { + val MY_SEPARATORS = + DEFAULT_SEPARATORS + .withObjectFieldValueSpacing(Separators.Spacing.AFTER) + } + + init { + indentArraysWith(DefaultIndenter(" ", "\n")) + } + + constructor(separators: Separators? = MY_SEPARATORS) : super(separators) + + constructor(base: InliningTagArrayPrettyPrinter) : super(base) + + override fun createInstance(): DefaultPrettyPrinter = InliningTagArrayPrettyPrinter(this) + + override fun writeStartArray(g: JsonGenerator) { + if (!_arrayIndenter.isInline) { + ++_nesting + } + g.writeRaw('[') + } + + override fun beforeArrayValues(g: JsonGenerator) { + if (_nesting < 3) { + _arrayIndenter.writeIndentation(g, _nesting) + } + } + + override fun writeArrayValueSeparator(g: JsonGenerator) { + g.writeRaw(_arrayValueSeparator) + if (_nesting < 3) { + _arrayIndenter.writeIndentation(g, _nesting) + } else { + g.writeRaw(' ') + } + } + + override fun writeEndArray( + g: JsonGenerator, + nrOfValues: Int, + ) { + if (!_arrayIndenter.isInline) { + --_nesting + } + if (nrOfValues > 0) { + if (_nesting < 2) { + _arrayIndenter.writeIndentation(g, _nesting) + } + } + g.writeRaw(']') + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt new file mode 100644 index 0000000000..be7172f11f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -0,0 +1,215 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata + +import android.util.Log +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud06Tag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud16Tag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.NameTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.Nip05Tag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.PictureTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.PronounsTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.WebsiteTag +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag +import com.vitorpamplona.quartz.nip39ExtIdentities.claims +import com.vitorpamplona.quartz.nip39ExtIdentities.githubClaim +import com.vitorpamplona.quartz.nip39ExtIdentities.mastodonClaim +import com.vitorpamplona.quartz.nip39ExtIdentities.replaceClaims +import com.vitorpamplona.quartz.nip39ExtIdentities.twitterClaim +import com.vitorpamplona.quartz.utils.TimeUtils + +class MetadataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun contactMetadataJson() = jacksonObjectMapper().readTree(content) as? ObjectNode + + fun contactMetaData() = + try { + EventMapper.mapper.readValue(content, UserMetadata::class.java) + } catch (e: Exception) { + Log.w("MetadataEvent", "Content Parse Error: ${toNostrUri()} ${e.localizedMessage}") + null + } + + companion object { + const val KIND = 0 + + fun newUser( + name: String?, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val metadata = JsonNodeFactory.instance.objectNode() + name?.let { addIfNotBlank(metadata, "name", it.trim()) } + return eventTemplate(KIND, metadata.toString(), createdAt) { + alt("User profile for $name") + initializer() + } + } + + fun createNew( + name: String? = null, + displayName: String? = null, + picture: String? = null, + banner: String? = null, + website: String? = null, + about: String? = null, + nip05: String? = null, + lnAddress: String? = null, + lnURL: String? = null, + pronouns: String? = null, + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + // Tries to not delete any existing attribute that we do not work with. + val currentMetadata = JsonNodeFactory.instance.objectNode() + + name?.let { addIfNotBlank(currentMetadata, NameTag.TAG_NAME, it.trim()) } + displayName?.let { addIfNotBlank(currentMetadata, DisplayNameTag.TAG_NAME, it.trim()) } + picture?.let { addIfNotBlank(currentMetadata, PictureTag.TAG_NAME, it.trim()) } + banner?.let { addIfNotBlank(currentMetadata, BannerTag.TAG_NAME, it.trim()) } + website?.let { addIfNotBlank(currentMetadata, WebsiteTag.TAG_NAME, it.trim()) } + pronouns?.let { addIfNotBlank(currentMetadata, PronounsTag.TAG_NAME, it.trim()) } + about?.let { addIfNotBlank(currentMetadata, AboutTag.TAG_NAME, it.trim()) } + nip05?.let { addIfNotBlank(currentMetadata, Nip05Tag.TAG_NAME, it.trim()) } + lnAddress?.let { addIfNotBlank(currentMetadata, Lud16Tag.TAG_NAME, it.trim()) } + lnURL?.let { addIfNotBlank(currentMetadata, Lud06Tag.TAG_NAME, it.trim()) } + + return eventTemplate(KIND, currentMetadata.toString(), createdAt) { + alt("User profile for ${currentMetadata.get("name").asText() ?: "Anonymous"}") + + // For https://github.com/nostr-protocol/nips/pull/1770 + currentMetadata.get(NameTag.TAG_NAME)?.asText()?.let { name(it) } + currentMetadata.get(DisplayNameTag.TAG_NAME)?.asText()?.let { displayName(it) } + currentMetadata.get(PictureTag.TAG_NAME)?.asText()?.let { picture(it) } + currentMetadata.get(BannerTag.TAG_NAME)?.asText()?.let { banner(it) } + currentMetadata.get(WebsiteTag.TAG_NAME)?.asText()?.let { website(it) } + currentMetadata.get(PronounsTag.TAG_NAME)?.asText()?.let { pronouns(it) } + currentMetadata.get(AboutTag.TAG_NAME)?.asText()?.let { about(it) } + currentMetadata.get(Nip05Tag.TAG_NAME)?.asText()?.let { nip05(it) } + currentMetadata.get(Lud16Tag.TAG_NAME)?.asText()?.let { lud16(it) } + currentMetadata.get(Lud06Tag.TAG_NAME)?.asText()?.let { lud06(it) } + + twitter?.let { twitterClaim(it) } + ?: mastodon?.let { mastodonClaim(it) } + github?.let { githubClaim(it) } + + initializer() + } + } + + /** + * Updates fields from the latest Metadata Event. Null params remain unchanged. Empty params get deleted. + */ + fun updateFromPast( + latest: MetadataEvent, + name: String? = null, + displayName: String? = null, + picture: String? = null, + banner: String? = null, + website: String? = null, + about: String? = null, + nip05: String? = null, + lnAddress: String? = null, + lnURL: String? = null, + pronouns: String? = null, + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + // Tries to not delete any existing attribute that we do not work with. + val currentMetadata = latest.contactMetadataJson() ?: JsonNodeFactory.instance.objectNode() + + name?.let { addIfNotBlank(currentMetadata, NameTag.TAG_NAME, it.trim()) } + displayName?.let { addIfNotBlank(currentMetadata, DisplayNameTag.TAG_NAME, it.trim()) } + picture?.let { addIfNotBlank(currentMetadata, PictureTag.TAG_NAME, it.trim()) } + banner?.let { addIfNotBlank(currentMetadata, BannerTag.TAG_NAME, it.trim()) } + website?.let { addIfNotBlank(currentMetadata, WebsiteTag.TAG_NAME, it.trim()) } + pronouns?.let { addIfNotBlank(currentMetadata, PronounsTag.TAG_NAME, it.trim()) } + about?.let { addIfNotBlank(currentMetadata, AboutTag.TAG_NAME, it.trim()) } + nip05?.let { addIfNotBlank(currentMetadata, Nip05Tag.TAG_NAME, it.trim()) } + lnAddress?.let { addIfNotBlank(currentMetadata, Lud16Tag.TAG_NAME, it.trim()) } + lnURL?.let { addIfNotBlank(currentMetadata, Lud06Tag.TAG_NAME, it.trim()) } + + val tags = + latest.tags.builder { + alt("User profile for ${currentMetadata.get("name").asText() ?: "Anonymous"}") + + // For https://github.com/nostr-protocol/nips/pull/1770 + currentMetadata.get(NameTag.TAG_NAME)?.asText()?.let { name(it) } ?: run { remove(NameTag.TAG_NAME) } + currentMetadata.get(DisplayNameTag.TAG_NAME)?.asText()?.let { displayName(it) } ?: run { remove(DisplayNameTag.TAG_NAME) } + currentMetadata.get(PictureTag.TAG_NAME)?.asText()?.let { picture(it) } ?: run { remove(PictureTag.TAG_NAME) } + currentMetadata.get(BannerTag.TAG_NAME)?.asText()?.let { banner(it) } ?: run { remove(BannerTag.TAG_NAME) } + currentMetadata.get(WebsiteTag.TAG_NAME)?.asText()?.let { website(it) } ?: run { remove(WebsiteTag.TAG_NAME) } + currentMetadata.get(PronounsTag.TAG_NAME)?.asText()?.let { pronouns(it) } ?: run { remove(PronounsTag.TAG_NAME) } + currentMetadata.get(AboutTag.TAG_NAME)?.asText()?.let { about(it) } ?: run { remove(AboutTag.TAG_NAME) } + currentMetadata.get(Nip05Tag.TAG_NAME)?.asText()?.let { nip05(it) } ?: run { remove(Nip05Tag.TAG_NAME) } + currentMetadata.get(Lud16Tag.TAG_NAME)?.asText()?.let { lud16(it) } ?: run { remove(Lud16Tag.TAG_NAME) } + currentMetadata.get(Lud06Tag.TAG_NAME)?.asText()?.let { lud06(it) } ?: run { remove(Lud06Tag.TAG_NAME) } + + val newClaims = latest.replaceClaims(twitter, mastodon, github) + remove(IdentityClaimTag.TAG_NAME) + claims(newClaims) + + initializer() + } + + return EventTemplate(createdAt, KIND, tags, currentMetadata.toString()) + } + + private fun addIfNotBlank( + currentJson: ObjectNode, + key: String, + value: String, + ) { + if (value.isBlank() || value == "null") { + currentJson.remove(key) + } else { + currentJson.put(key, value.trim()) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..0913027f1f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud06Tag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud16Tag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.NameTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.Nip05Tag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.PictureTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.PronounsTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.WebsiteTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.displayName(name: String) = addUnique(DisplayNameTag.assemble(name)) + +fun TagArrayBuilder.picture(picture: String) = addUnique(PictureTag.assemble(picture)) + +fun TagArrayBuilder.about(about: String) = addUnique(AboutTag.assemble(about)) + +fun TagArrayBuilder.website(url: String) = addUnique(WebsiteTag.assemble(url)) + +fun TagArrayBuilder.nip05(nip05: String) = addUnique(Nip05Tag.assemble(nip05)) + +fun TagArrayBuilder.lud16(lud16: String) = addUnique(Lud16Tag.assemble(lud16)) + +fun TagArrayBuilder.lud06(lud06: String) = addUnique(Lud06Tag.assemble(lud06)) + +fun TagArrayBuilder.banner(banner: String) = addUnique(BannerTag.assemble(banner)) + +fun TagArrayBuilder.pronouns(pronouns: String) = addUnique(PronounsTag.assemble(pronouns)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/UserMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt similarity index 98% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/UserMetadata.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index 6fe44d511e..10c8513df0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/UserMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core +package com.vitorpamplona.quartz.nip01Core.metadata import androidx.compose.runtime.Stable import com.fasterxml.jackson.annotation.JsonProperty diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/AboutTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/AboutTag.kt new file mode 100644 index 0000000000..dfe15e9b69 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/AboutTag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class AboutTag { + companion object { + const val TAG_NAME = "about" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/BannerTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/BannerTag.kt new file mode 100644 index 0000000000..6189b81c6e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/BannerTag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class BannerTag { + companion object { + const val TAG_NAME = "banner" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/DisplayNameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/DisplayNameTag.kt new file mode 100644 index 0000000000..d96b32d520 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/DisplayNameTag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class DisplayNameTag { + companion object { + const val TAG_NAME = "display_name" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud06Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud06Tag.kt new file mode 100644 index 0000000000..208af58652 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud06Tag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class Lud06Tag { + companion object { + const val TAG_NAME = "lud06" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud16Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud16Tag.kt new file mode 100644 index 0000000000..120c28f632 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud16Tag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class Lud16Tag { + companion object { + const val TAG_NAME = "lud16" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/NameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/NameTag.kt new file mode 100644 index 0000000000..3b4108d1d7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/NameTag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class NameTag { + companion object { + const val TAG_NAME = "name" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Nip05Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Nip05Tag.kt new file mode 100644 index 0000000000..67c33fc8ab --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Nip05Tag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class Nip05Tag { + companion object { + const val TAG_NAME = "nip05" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PictureTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PictureTag.kt new file mode 100644 index 0000000000..782e27bd8c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PictureTag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class PictureTag { + companion object { + const val TAG_NAME = "picture" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PronounsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PronounsTag.kt new file mode 100644 index 0000000000..5ebf5a3827 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PronounsTag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class PronounsTag { + companion object { + const val TAG_NAME = "pronouns" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/WebsiteTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/WebsiteTag.kt new file mode 100644 index 0000000000..71468bd63b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/WebsiteTag.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +class WebsiteTag { + companion object { + const val TAG_NAME = "website" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/RelayStat.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt similarity index 98% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/RelayStat.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt index c63fa52dba..fa7870141d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/RelayStat.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays +package com.vitorpamplona.quartz.nip01Core.relay import androidx.collection.LruCache import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/RelayState.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt similarity index 96% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/RelayState.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt index caf9fb9251..49ea625d31 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/RelayState.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays +package com.vitorpamplona.quartz.nip01Core.relay enum class RelayState { // Websocket connected diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/SimpleClientRelay.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt similarity index 91% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/SimpleClientRelay.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt index 12c2477dd2..9a373862a1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/SimpleClientRelay.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt @@ -18,27 +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.nip01Core.relays +package com.vitorpamplona.quartz.nip01Core.relay import android.util.Log -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relays.commands.toClient.AuthMessage -import com.vitorpamplona.quartz.nip01Core.relays.commands.toClient.ClosedMessage -import com.vitorpamplona.quartz.nip01Core.relays.commands.toClient.EoseMessage -import com.vitorpamplona.quartz.nip01Core.relays.commands.toClient.EventMessage -import com.vitorpamplona.quartz.nip01Core.relays.commands.toClient.NoticeMessage -import com.vitorpamplona.quartz.nip01Core.relays.commands.toClient.NotifyMessage -import com.vitorpamplona.quartz.nip01Core.relays.commands.toClient.OkMessage -import com.vitorpamplona.quartz.nip01Core.relays.commands.toClient.ToClientParser -import com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay.AuthCmd -import com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay.CloseCmd -import com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay.CountCmd -import com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relays.sockets.WebSocket -import com.vitorpamplona.quartz.nip01Core.relays.sockets.WebSocketListener -import com.vitorpamplona.quartz.nip01Core.relays.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ToClientParser +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -263,7 +263,7 @@ class SimpleClientRelay( } if (!msg.success) { - stats.newNotice("Rejected event $msg.eventId: $msg.message") + stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}") } listener.onSendResponse(this@SimpleClientRelay, msg.eventId, msg.success, msg.message) @@ -313,7 +313,7 @@ class SimpleClientRelay( if (isReady) { if (filters.isNotEmpty()) { writeToSocket( - com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay.ReqCmd + com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd .toJson(requestId, filters), ) afterEOSEPerSubscription[requestId] = false diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/Subscription.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt similarity index 91% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/Subscription.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt index 5a83bc4af8..3f06060b2a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/Subscription.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR 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.relays +package com.vitorpamplona.quartz.nip01Core.relay -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import java.util.UUID class Subscription( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/SubscriptionCollection.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt similarity index 92% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/SubscriptionCollection.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt index b1d4252c0c..3f2346bdde 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/SubscriptionCollection.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.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.nip01Core.relays +package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter interface SubscriptionCollection { fun isActive(subscriptionId: String): Boolean diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/AuthMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/AuthMessage.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/AuthMessage.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/AuthMessage.kt index ff45dda0cb..c1bff67b6c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/AuthMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/AuthMessage.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toClient +package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.databind.JsonNode diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/ClosedMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/ClosedMessage.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt index 12aae77f7b..8cf16f8078 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/ClosedMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toClient +package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.databind.JsonNode diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/EoseMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/EoseMessage.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt index fcc100b9b7..455090480d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/EoseMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toClient +package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.databind.JsonNode diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/EventMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt similarity index 96% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/EventMessage.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt index 2f5f60f874..f4957abd34 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/EventMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toClient +package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.databind.JsonNode import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/Message.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt similarity index 94% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/Message.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt index a025f55286..64dcfcc735 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/Message.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt @@ -18,6 +18,6 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toClient +package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient interface Message diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/NoticeMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/NoticeMessage.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt index 7ad6a35b66..eff9c10a5e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/NoticeMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toClient +package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.databind.JsonNode diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/NotifyMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NotifyMessage.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/NotifyMessage.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NotifyMessage.kt index 06ece97638..30ace93e76 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/NotifyMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NotifyMessage.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toClient +package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.databind.JsonNode diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/OkMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt similarity index 92% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/OkMessage.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt index bbcdbea580..492114465d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/OkMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.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.nip01Core.relays.commands.toClient +package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.databind.JsonNode -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey class OkMessage( val eventId: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/ToClientParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt similarity index 96% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/ToClientParser.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt index 4abf3f552f..ea654aebae 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toClient/ToClientParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toClient +package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/AuthCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt similarity index 96% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/AuthCmd.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt index a9451858c1..fd35bb14df 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/AuthCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay +package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/CloseCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CloseCmd.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/CloseCmd.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CloseCmd.kt index ba44bff653..c2f5d08ac1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/CloseCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CloseCmd.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay +package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/Command.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt similarity index 94% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/Command.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt index b68b114bf0..50c4c7d8f4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/Command.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt @@ -18,6 +18,6 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay +package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay interface Command diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/CountCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt similarity index 91% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/CountCmd.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt index 36a8118085..b0ea5d1e0b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/CountCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay +package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relays.filters.FilterDeserializer +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterDeserializer import com.vitorpamplona.quartz.utils.joinToStringLimited class CountCmd( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/EventCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt similarity index 96% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/EventCmd.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt index 54f33ae700..002e170284 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/EventCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay +package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/ReqCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt similarity index 85% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/ReqCmd.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt index 2e703d8605..7d93b978f7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/ReqCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay +package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relays.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relays.filters.FilterDeserializer +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterDeserializer import com.vitorpamplona.quartz.utils.joinToStringLimited class ReqCmd( val subscriptionId: String, val filters: List, -) : com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay.Command { +) : com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command { companion object { const val LABEL = "REQ" @@ -50,7 +50,7 @@ class ReqCmd( } @JvmStatic - fun parse(msgArray: JsonNode): com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay.ReqCmd { + fun parse(msgArray: JsonNode): com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd { val filters = mutableListOf() for (i in 2 until msgArray.size()) { @@ -60,7 +60,7 @@ class ReqCmd( } } - return com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay.ReqCmd( + return com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd( msgArray.get(1).asText(), filters, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/ToRelayParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt similarity index 96% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/ToRelayParser.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt index ba45a5020b..b90ad14e48 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/commands/toRelay/ToRelayParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.commands.toRelay +package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/Filter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt similarity index 97% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/Filter.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt index 7bd48bd03d..ec7864c089 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/Filter.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.filters +package com.vitorpamplona.quartz.nip01Core.relay.filters import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/FilterDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt similarity index 97% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/FilterDeserializer.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt index 08260a6d94..fd5b1a1052 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/FilterDeserializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.filters +package com.vitorpamplona.quartz.nip01Core.relay.filters import com.fasterxml.jackson.databind.node.ObjectNode diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/FilterMatcher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt similarity index 97% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/FilterMatcher.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt index 7a0bbb43c5..c354ec4d48 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/FilterMatcher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.filters +package com.vitorpamplona.quartz.nip01Core.relay.filters import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/FilterSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt similarity index 98% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/FilterSerializer.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt index 145c8798ad..830a0eb865 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/filters/FilterSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.filters +package com.vitorpamplona.quartz.nip01Core.relay.filters import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebSocket.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebSocket.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt index 9fa6598bb8..36387e350c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebSocket.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.sockets +package com.vitorpamplona.quartz.nip01Core.relay.sockets interface WebSocket { fun connect() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebSocketListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt similarity index 96% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebSocketListener.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt index 69dadfb1d6..8d6499d2ae 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebSocketListener.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.sockets +package com.vitorpamplona.quartz.nip01Core.relay.sockets interface WebSocketListener { fun onOpen( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebsocketBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebsocketBuilder.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt index e8fd6960ee..a5c11a1672 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebsocketBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.sockets +package com.vitorpamplona.quartz.nip01Core.relay.sockets interface WebsocketBuilder { fun build( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebsocketBuilderFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebsocketBuilderFactory.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt index 3a4a50f14d..1cbcdddd99 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relays/sockets/WebsocketBuilderFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relays.sockets +package com.vitorpamplona.quartz.nip01Core.relay.sockets interface WebsocketBuilderFactory { fun build(forceProxy: Boolean): WebsocketBuilder diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt new file mode 100644 index 0000000000..60d7527d2a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.signers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.utils.TimeUtils + +class EventTemplate( + val createdAt: Long, + val kind: Int, + val tags: TagArray, + val content: String, +) + +inline fun eventTemplate( + kind: Int, + description: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, +) = EventTemplate(createdAt, kind, tagArray(initializer), description) + +fun eventUpdate( + base: Event, + createdAt: Long = TimeUtils.now(), + updater: TagArrayBuilder.() -> Unit = {}, +) = EventTemplate(createdAt, base.kind, base.tags.builder(updater), base.content) 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 d359c1f46c..af3f876f39 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 @@ -21,16 +21,21 @@ package com.vitorpamplona.quartz.nip01Core.signers import com.vitorpamplona.quartz.EventFactory -import com.vitorpamplona.quartz.nip01Core.EventHasher -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip04Dm.Nip04 +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 abstract class NostrSigner( val pubKey: HexKey, ) { + fun sign( + ev: EventTemplate, + onReady: (T) -> Unit, + ) = sign(ev.createdAt, ev.kind, ev.tags, ev.content, onReady) + abstract fun sign( createdAt: Long, kind: Int, @@ -73,13 +78,18 @@ abstract class NostrSigner( fromPublicKey: HexKey, onReady: (String) -> Unit, ) { - if (Nip04.isNIP04(encryptedContent)) { + if (EncryptedInfo.isNIP04(encryptedContent)) { nip04Decrypt(encryptedContent, fromPublicKey, onReady) } else { nip44Decrypt(encryptedContent, fromPublicKey, onReady) } } + fun assembleRumor( + ev: EventTemplate, + onReady: (T) -> Unit, + ) = assembleRumor(ev.createdAt, ev.kind, ev.tags, ev.content, onReady) + fun assembleRumor( createdAt: Long, kind: Int, 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 b56aff3206..c49e52bc29 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 @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.signers -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.toHexKey +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.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent 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 0d633cd4ec..112e0eae4d 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 @@ -21,22 +21,24 @@ package com.vitorpamplona.quartz.nip01Core.signers import android.util.Log -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.EventFactory -import com.vitorpamplona.quartz.nip01Core.EventHasher -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.toHexKey +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.EventAssembler +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 +import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapRequestBuilder class NostrSignerSync( - val keyPair: KeyPair, + val keyPair: KeyPair = KeyPair(), val pubKey: HexKey = keyPair.pubKey.toHexKey(), ) { + fun sign(ev: EventTemplate) = signNormal(ev.createdAt, ev.kind, ev.tags, ev.content) + fun sign( createdAt: Long, kind: Int, @@ -68,18 +70,7 @@ class NostrSignerSync( ): T? { if (keyPair.privKey == null) return null - val id = EventHasher.hashIdBytes(pubKey, createdAt, kind, tags, content) - val sig = CryptoUtils.sign(id, keyPair.privKey).toHexKey() - - return EventFactory.create( - id.toHexKey(), - pubKey, - createdAt, - kind, - tags, - content, - sig, - ) as T + return EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, keyPair.privKey) } fun nip04Encrypt( @@ -88,7 +79,7 @@ class NostrSignerSync( ): String? { if (keyPair.privKey == null) return null - return CryptoUtils.encryptNIP04( + return Nip04.encrypt( decryptedContent, keyPair.privKey, toPublicKey.hexToByteArray(), @@ -102,10 +93,7 @@ class NostrSignerSync( if (keyPair.privKey == null) return null return try { - val sharedSecret = - CryptoUtils.getSharedSecretNIP04(keyPair.privKey, fromPublicKey.hexToByteArray()) - - CryptoUtils.decryptNIP04(encryptedContent, sharedSecret) + Nip04.decrypt(encryptedContent, keyPair.privKey, fromPublicKey.hexToByteArray()) } catch (e: Exception) { Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent") null @@ -118,8 +106,8 @@ class NostrSignerSync( ): String? { if (keyPair.privKey == null) return null - return CryptoUtils - .encryptNIP44( + return Nip44 + .encrypt( decryptedContent, keyPair.privKey, toPublicKey.hexToByteArray(), @@ -132,12 +120,11 @@ class NostrSignerSync( ): String? { if (keyPair.privKey == null) return null - return CryptoUtils - .decryptNIP44( - payload = encryptedContent, - privateKey = keyPair.privKey, - pubKey = fromPublicKey.hexToByteArray(), - ) + return Nip44.decrypt( + payload = encryptedContent, + privateKey = keyPair.privKey, + pubKey = fromPublicKey.hexToByteArray(), + ) } fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent? = PrivateZapRequestBuilder().decryptZapEvent(event, this) 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 2752f19142..2f8e464d1e 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 @@ -20,12 +20,16 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.addressables -import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.name +import com.vitorpamplona.quartz.nip01Core.core.value +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.pointerSizeInBytes import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @@ -34,17 +38,9 @@ data class ATag( val kind: Int, val pubKeyHex: String, val dTag: String, + val relay: String? = null, ) { - var relay: String? = null - - constructor( - kind: Int, - pubKeyHex: HexKey, - dTag: String, - relayHint: String?, - ) : this(kind, pubKeyHex, dTag) { - this.relay = relayHint - } + constructor(address: Address, relayHint: String? = null) : this(address.kind, address.pubKeyHex, address.dTag, relayHint) fun countMemory(): Long = 5 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit) @@ -53,7 +49,7 @@ data class ATag( dTag.bytesUsedInMemory() + (relay?.bytesUsedInMemory() ?: 0) - fun toTag() = assembleATagId(kind, pubKeyHex, dTag) + fun toTag() = Address.assemble(kind, pubKeyHex, dTag) fun toATagArray() = removeTrailingNullsAndEmptyOthers(TAG_NAME, toTag(), relay) @@ -61,34 +57,99 @@ data class ATag( companion object { const val TAG_NAME = "a" + const val TAG_SIZE = 2 - fun assembleATagId( - kind: Int, - pubKeyHex: HexKey, - dTag: String, - ) = "$kind:$pubKeyHex:$dTag" + @JvmStatic + fun isTagged(tag: Array) = tag.size >= 2 && tag[0] == TAG_NAME && tag[1].isNotEmpty() + @JvmStatic + fun isSameAddress( + tag1: Array, + tag2: Array, + ) = tag1.match(tag2.name(), tag2.value(), TAG_SIZE) + + @JvmStatic + fun isTagged( + tag: Array, + addressId: String, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1] == addressId + + @JvmStatic + fun isTagged( + tag: Array, + address: ATag, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1] == address.toTag() + + @JvmStatic + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1] in addressIds + + @JvmStatic + fun isTaggedWithKind( + tag: Array, + kind: String, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind) + + @JvmStatic + fun parseIfOfKind( + tag: Array, + kind: String, + ) = if (isTaggedWithKind(tag, kind)) parse(tag[1], tag.getOrNull(2)) else null + + @JvmStatic + fun parseIfIsIn( + tag: Array, + addresses: Set, + ) = if (isIn(tag, addresses)) parse(tag[1], tag.getOrNull(2)) else null + + @JvmStatic fun parse( aTagId: String, relay: String?, - ): ATag? = - try { - val parts = aTagId.split(":", limit = 3) - if (Hex.isHex(parts[1])) { - ATag(parts[0].toInt(), parts[1], parts[2], relay) - } else { - Log.w("ATag", "Error parsing A Tag. Pubkey is not hex: $aTagId") - null - } - } catch (t: Throwable) { - Log.w("ATag", "Error parsing A Tag: $aTagId: ${t.message}") - null - } + ) = Address.parse(aTagId)?.let { ATag(it.kind, it.pubKeyHex, it.dTag, relay) } @JvmStatic - fun parse(tags: Array): ATag? { - require(tags[0] == TAG_NAME) - return parse(tags[1], tags.getOrNull(2)) + fun parse(tag: Array): ATag? { + 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 } + return AddressHint(tag[1], tag[2]) } @JvmStatic @@ -100,9 +161,9 @@ data class ATag( @JvmStatic fun assemble( kind: Int, - pubKeyHex: String, + pubKey: String, dTag: String, relay: String?, - ) = arrayOfNotNull(TAG_NAME, assembleATagId(kind, pubKeyHex, dTag), relay) + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt new file mode 100644 index 0000000000..d09eae45b0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.addressables + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +data class Address( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, +) { + fun toValue() = assemble(kind, pubKeyHex, dTag) + + fun countMemory(): Long = + 3 * pointerSizeInBytes + + 8L + // kind + pubKeyHex.bytesUsedInMemory() + + dTag.bytesUsedInMemory() + + companion object { + fun assemble( + kind: Int, + pubKeyHex: HexKey, + dTag: String, + ) = "$kind:$pubKeyHex:$dTag" + + @JvmStatic + fun parse(addressId: String): Address? = + try { + val parts = addressId.split(":", limit = 3) + if (parts.size > 1 && parts[1].length == 64 && Hex.isHex(parts[1])) { + Address(parts[0].toInt(), parts[1], parts[2]) + } else { + Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId") + null + } + } catch (t: Throwable) { + Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t) + null + } + + fun isOfKind( + addressId: String, + kind: String, + ) = addressId.startsWith(kind) && addressId[kind.length] == ':' + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt index d0ba9582ed..1759536487 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt @@ -34,6 +34,10 @@ fun Event.isTaggedAddressableKind(kind: Int) = tags.isTaggedAddressableKind(kind fun Event.getTagOfAddressableKind(kind: Int) = tags.getTagOfAddressableKind(kind) +fun Event.taggedATags() = tags.taggedATags() + +fun Event.firstTaggedATag() = tags.firstTaggedATag() + fun Event.taggedAddresses() = tags.taggedAddresses() fun Event.firstTaggedAddress() = tags.firstTaggedAddress() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d59535ad3a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.addressables + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.aTag(tag: ATag) = add(tag.toATagArray()) + +fun TagArrayBuilder.aTags(tag: List) = addAll(tag.map { it.toATagArray() }) + +fun TagArrayBuilder.removeATag(tag: ATag) = this.removeIf(ATag::isSameAddress, tag.toATagArray()) + +fun TagArrayBuilder.qTag(tag: ATag) = add(tag.toQTagArray()) + +fun TagArrayBuilder.qTags(tag: List) = addAll(tag.map { it.toQTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayExt.kt index 00da4d1d53..d7e8deb0e8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayExt.kt @@ -21,40 +21,26 @@ package com.vitorpamplona.quartz.nip01Core.tags.addressables import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.firstMapTagged -import com.vitorpamplona.quartz.nip01Core.core.isAnyTagged -import com.vitorpamplona.quartz.nip01Core.core.isTagged -import com.vitorpamplona.quartz.nip01Core.core.mapTagged +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.core.firstNotNullOfOrNull import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged -import com.vitorpamplona.quartz.nip19Bech32.parse fun TagArray.mapTaggedAddress(map: (address: String) -> R) = this.mapValueTagged(ATag.TAG_NAME, map) -fun TagArray.firstIsTaggedAddressableNote(addressableNotes: Set) = - this - .firstOrNull { it.size > 1 && it[0] == ATag.TAG_NAME && it[1] in addressableNotes } - ?.getOrNull(1) +fun TagArray.firstIsTaggedAddressableNote(addressableNotes: Set) = this.firstNotNullOfOrNull(ATag::parseIfIsIn, addressableNotes) -fun TagArray.isTaggedAddressableNote(idHex: String) = this.isTagged(ATag.TAG_NAME, idHex) +fun TagArray.isTaggedAddressableNote(addressId: String) = this.any(ATag::isTagged, addressId) -fun TagArray.isTaggedAddressableNotes(idHexes: Set) = this.isAnyTagged(ATag.TAG_NAME, idHexes) +fun TagArray.isTaggedAddressableNotes(addressIds: Set) = this.any(ATag::isIn, addressIds) -fun TagArray.isTaggedAddressableKind(kind: Int): Boolean { - val kindStr = kind.toString() - return this.any { it.size > 1 && it[0] == ATag.TAG_NAME && it[1].startsWith(kindStr) } -} +fun TagArray.isTaggedAddressableKind(kind: Int) = this.any(ATag::isTaggedWithKind, kind.toString()) -fun TagArray.getTagOfAddressableKind(kind: Int): ATag? { - val kindStr = kind.toString() - val aTag = - this - .firstOrNull { it.size > 1 && it[0] == ATag.TAG_NAME && it[1].startsWith(kindStr) } - ?.getOrNull(1) - ?: return null +fun TagArray.getTagOfAddressableKind(kind: Int) = this.firstNotNullOfOrNull(ATag::parseIfOfKind, kind.toString()) - return ATag.parse(aTag, null) -} +fun TagArray.taggedATags() = this.mapNotNull(ATag::parse) -fun TagArray.taggedAddresses() = this.mapTagged(ATag.TAG_NAME) { ATag.parse(it) } +fun TagArray.firstTaggedATag() = this.firstNotNullOfOrNull(ATag::parse) -fun TagArray.firstTaggedAddress() = this.firstMapTagged(ATag.TAG_NAME) { ATag.parse(it) } +fun TagArray.taggedAddresses() = this.mapNotNull(ATag::parseAddress) + +fun TagArray.firstTaggedAddress() = this.firstNotNullOfOrNull(ATag::parseAddress) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayBuilderExt.kt index 96fc5e73bb..a85f6be7bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayBuilderExt.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.dTags +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -fun TagArrayBuilder.dTag(name: String) = add(DTag.assemble(name)) +fun TagArrayBuilder.dTag(name: String) = addUnique(DTag.assemble(name)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt index 10d45bd70a..0f59c78978 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt @@ -21,7 +21,8 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -29,35 +30,59 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable data class ETag( - val eventId: HexKey, -) { - var relay: String? = null - var authorPubKeyHex: HexKey? = null + override val eventId: HexKey, +) : GenericETag { + override var relay: String? = null + override var author: HexKey? = null constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint - this.authorPubKeyHex = authorPubKeyHex + this.author = authorPubKeyHex } fun countMemory(): Long = 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) eventId.bytesUsedInMemory() + (relay?.bytesUsedInMemory() ?: 0) + - (authorPubKeyHex?.bytesUsedInMemory() ?: 0) + (author?.bytesUsedInMemory() ?: 0) - fun toNEvent(): String = NEvent.create(eventId, authorPubKeyHex, null, relay) + fun toNEvent(): String = NEvent.create(eventId, author, null, relay) - fun toETagArray() = arrayOfNotNull(TAG_NAME, eventId, relay, authorPubKeyHex) + override fun toTagArray() = toNamedTagArray(TAG_NAME) - fun toQTagArray() = arrayOfNotNull("q", eventId, relay, authorPubKeyHex) + fun toQTagArray() = toNamedTagArray("q") + + fun toNamedTagArray(key: String) = arrayOfNotNull(key, eventId, relay, author) companion object { const val TAG_NAME = "e" + const val TAG_SIZE = 2 @JvmStatic - fun parse(tags: Array): ETag { - require(tags[0] == TAG_NAME) - return ETag(tags[1], tags.getOrNull(2), tags.getOrNull(3)) + fun isTagged(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1].length == 64 + + @JvmStatic + fun isTagged( + tag: Array, + eventId: HexKey, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1] == eventId + + @JvmStatic + fun parse(tag: Array): ETag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME || tag[1].length != 64) return null + return ETag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + } + + @JvmStatic + fun parseId(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME || tag[1].length != 64) return null + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length != 64 || tag[2].isEmpty()) return null + return EventIdHint(tag[1], tag[2]) } @JvmStatic diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventExt.kt index eea195ef91..883b722d4e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventExt.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.events -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey fun Event.forEachTaggedEventId(onEach: (eventId: HexKey) -> Unit) = tags.forEachTaggedEventId(onEach) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt new file mode 100644 index 0000000000..8728ff3b4b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.events + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +class EventReference( + val eventId: HexKey, + val author: HexKey?, + val relayHint: String?, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt new file mode 100644 index 0000000000..b10a836f42 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.events + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +interface GenericETag { + val eventId: HexKey + val relay: String? + val author: HexKey? + + fun toTagArray(): Array +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..260d464e15 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.events + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.eTag(tag: ETag) = add(tag.toTagArray()) + +fun TagArrayBuilder.eTags(tag: List) = addAll(tag.map { it.toTagArray() }) + +fun TagArrayBuilder.qTag(tag: ETag) = add(tag.toQTagArray()) + +fun TagArrayBuilder.qTags(tag: List) = addAll(tag.map { it.toQTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayExt.kt index 5010c44c2e..88c11e6fad 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayExt.kt @@ -20,24 +20,20 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.events -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.firstMapTagged +import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.core.forEachTagged -import com.vitorpamplona.quartz.nip01Core.core.isTagged -import com.vitorpamplona.quartz.nip01Core.core.mapTagged import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged -import com.vitorpamplona.quartz.nip01Core.core.mapValues -import com.vitorpamplona.quartz.nip19Bech32.parse fun TagArray.forEachTaggedEventId(onEach: (eventId: HexKey) -> Unit) = this.forEachTagged(ETag.TAG_NAME, onEach) fun TagArray.mapTaggedEventId(map: (eventId: HexKey) -> R) = this.mapValueTagged(ETag.TAG_NAME, map) -fun TagArray.taggedEvents() = this.mapTagged(ETag.TAG_NAME) { ETag.parse(it) } +fun TagArray.taggedEvents() = this.mapNotNull(ETag::parse) -fun TagArray.taggedEventIds() = this.mapValues(ETag.TAG_NAME) +fun TagArray.taggedEventIds() = this.mapNotNull(ETag::parseId) -fun TagArray.firstTaggedEvent() = this.firstMapTagged(ETag.TAG_NAME) { ETag.parse(it) } +fun TagArray.firstTaggedEvent() = this.firstNotNullOfOrNull(ETag::parse) -fun TagArray.isTaggedEvent(idHex: String) = this.isTagged(ETag.TAG_NAME, idHex) +fun TagArray.isTaggedEvent(idHex: String) = this.any(ETag::isTagged, idHex) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt index d4006c78af..650351ccd7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt @@ -22,10 +22,15 @@ package com.vitorpamplona.quartz.nip01Core.tags.geohash import com.vitorpamplona.quartz.nip01Core.core.TagArray -fun geohashMipMap(geohash: String): TagArray = - geohash.indices - .asSequence() - .map { arrayOf("g", geohash.substring(0, it + 1)) } - .toList() - .reversed() - .toTypedArray() +class GeoHash { + companion object { + const val TAG_NAME = "g" + + @JvmStatic + fun geoMipMap(geohash: String): List = geohash.indices.map { geohash.substring(0, it + 1) }.reversed() + + fun geohashMipMap(geohash: String): TagArray = geoMipMap(geohash).map { arrayOf(TAG_NAME, it) }.toTypedArray() + + fun assemble(geohash: String) = geohashMipMap(geohash) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeohashPrecision.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeohashPrecision.kt new file mode 100644 index 0000000000..98109f3749 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeohashPrecision.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.geohash + +enum class GeohashPrecision( + val digits: Int, +) { + KM_5000_X_5000(1), // 5,000km × 5,000km + KM_1250_X_625(2), // 1,250km × 625km + KM_156_X_156(3), // 156km × 156km + KM_39_X_19(4), // 39.1km × 19.5km + KM_5_X_5(5), // 4.89km × 4.89km + M_1000_X_600(6), // 1.22km × 0.61km + M_153_X_153(7), // 153m × 153m + M_38_X_19(8), // 38.2m × 19.1m + M_5_X_5(9), // 4.77m × 4.77m + MM_1000_X_1000(10), // 1.19m × 0.596m + MM_149_X_149(11), // 149mm × 149mm + MM_37_X_18(12), // 37.2mm × 18.6mm +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..660315e321 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.geohash + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.geohash(tag: String) = addAll(GeoHash.assemble(tag)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayExt.kt index a5aef9f3ff..f4be576597 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayExt.kt @@ -26,12 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent import com.vitorpamplona.quartz.nip01Core.core.isAnyTagged import com.vitorpamplona.quartz.nip01Core.core.mapValues -fun TagArray.hasGeohashes() = this.hasTagWithContent("g") +fun TagArray.hasGeohashes() = this.hasTagWithContent(GeoHash.TAG_NAME) -fun TagArray.isTaggedGeoHashes(hashtags: Set) = this.isAnyTagged("g", hashtags) +fun TagArray.isTaggedGeoHashes(hashtags: Set) = this.isAnyTagged(GeoHash.TAG_NAME, hashtags) -fun TagArray.isTaggedGeoHash(hashtag: String) = this.anyTagWithValueStartingWith("g", hashtag) +fun TagArray.isTaggedGeoHash(hashtag: String) = this.anyTagWithValueStartingWith(GeoHash.TAG_NAME, hashtag) -fun TagArray.geohashes() = this.mapValues("g") +fun TagArray.geohashes() = this.mapValues(GeoHash.TAG_NAME) fun TagArray.getGeoHash(): String? = geohashes().maxByOrNull { it.length }?.ifBlank { null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt index 45c55bbc06..81da5a7d02 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.hashtags -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey fun Event.forEachHashTag(onEach: (eventId: HexKey) -> Unit) = tags.forEachHashTag(onEach) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt new file mode 100644 index 0000000000..773b14db27 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.hashtags + +class HashtagTag { + companion object { + const val TAG_NAME = "t" + const val TAG_SIZE = 2 + + @JvmStatic + fun isTagged(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME || tag[1].isEmpty()) return null + return tag[1] + } + + fun assemble(name: String) = arrayOf(TAG_NAME, name) + + fun assembleDualCase(name: String): List> { + val lowercaseTag = name.lowercase() + return if (name != lowercaseTag) { + listOf(assemble(name), assemble(lowercaseTag)) + } else { + listOf(assemble(name)) + } + } + + fun assemble(tags: List): List> { + val uniqueTags = mutableSetOf() + + tags.forEach { tag -> + uniqueTags.add(tag) + val lowercaseTag = tag.lowercase() + if (tag != lowercaseTag) { + uniqueTags.add(lowercaseTag) + } + } + + return uniqueTags.map { assemble(it) } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashTags.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayBuilderExt.kt similarity index 76% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashTags.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayBuilderExt.kt index f2c124274f..a6f7cee6de 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashTags.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayBuilderExt.kt @@ -20,18 +20,9 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.hashtags -fun buildHashtagTags(tags: List): List> { - val uniqueTags = mutableSetOf() +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder - tags.forEach { tag -> - uniqueTags.add(tag) - val lowercaseTag = tag.lowercase() - if (tag != lowercaseTag) { - uniqueTags.add(lowercaseTag) - } - } +fun TagArrayBuilder.hashtag(tag: String) = addAll(HashtagTag.assembleDualCase(tag)) - return uniqueTags.map { - arrayOf("t", it) - } -} +fun TagArrayBuilder.hashtags(tag: List) = addAll(HashtagTag.assemble(tag)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt index 4509f28415..849e381672 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt @@ -20,23 +20,28 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.hashtags -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.anyTagged +import com.vitorpamplona.quartz.nip01Core.core.firstAnyLowercaseTaggedValue import com.vitorpamplona.quartz.nip01Core.core.forEachTagged import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent +import com.vitorpamplona.quartz.nip01Core.core.isAnyLowercaseTagged +import com.vitorpamplona.quartz.nip01Core.core.isTagged import com.vitorpamplona.quartz.nip01Core.core.mapValues -fun TagArray.forEachHashTag(onEach: (eventId: HexKey) -> Unit) = this.forEachTagged("t", onEach) +fun TagArray.forEachHashTag(onEach: (eventId: HexKey) -> Unit) = this.forEachTagged(HashtagTag.TAG_NAME, onEach) -fun TagArray.anyHashTag(onEach: (str: String) -> Boolean) = this.anyTagged("t", onEach) +fun TagArray.anyHashTag(onEach: (str: String) -> Boolean) = this.anyTagged(HashtagTag.TAG_NAME, onEach) -fun TagArray.hasHashtags() = this.hasTagWithContent("t") +fun TagArray.hasHashtags() = this.hasTagWithContent(HashtagTag.TAG_NAME) -fun TagArray.hashtags() = this.mapValues("t") +fun TagArray.hashtags() = this.mapValues(HashtagTag.TAG_NAME) -fun TagArray.isTaggedHash(hashtag: String) = this.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) } +fun TagArray.countHashtags() = this.count(HashtagTag::isTagged) -fun TagArray.isTaggedHashes(hashtags: Set) = this.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags } +fun TagArray.isTaggedHash(hashtag: String) = this.isTagged(HashtagTag.TAG_NAME, hashtag, true) -fun TagArray.firstIsTaggedHashes(hashtags: Set) = this.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1) +fun TagArray.isTaggedHashes(hashtags: Set) = this.isAnyLowercaseTagged(HashtagTag.TAG_NAME, hashtags) + +fun TagArray.firstIsTaggedHashes(hashtags: Set) = this.firstAnyLowercaseTaggedValue(HashtagTag.TAG_NAME, hashtags) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/EventExt.kt new file mode 100644 index 0000000000..14fa8e1eb9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/EventExt.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.kinds + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +fun Event.forEachKindTag(onEach: (eventId: HexKey) -> Unit) = tags.forEachKind(onEach) + +fun Event.anyKindTag(onEach: (str: String) -> Boolean) = tags.anyKind(onEach) + +fun Event.hasKindTag() = tags.hasKind() + +fun Event.kinds() = tags.kinds() + +fun Event.isTaggedKind(kind: Int) = tags.isTaggedKind(kind) + +fun Event.isTaggedKinds(kinds: Set) = tags.isTaggedKinds(kinds) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt new file mode 100644 index 0000000000..0511994645 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.kinds + +class KindTag { + companion object { + const val TAG_NAME = "k" + const val TAG_SIZE = 2 + + fun match(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME + + fun isTagged( + tag: Array, + kind: String, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1] == kind + + fun isIn( + tag: Array, + kinds: Set, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1] in kinds + + @JvmStatic + fun parse(tag: Array): Int? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toInt() + } + + fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString()) + + fun assemble(kinds: List): List> = kinds.map { assemble(it) } + + fun assemble(kinds: Set): List> = kinds.map { assemble(it) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..913f096f5c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.kinds + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.kind(kind: Int) = add(KindTag.assemble(kind)) + +fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) + +fun TagArrayBuilder.kinds(kinds: Set) = addAll(KindTag.assemble(kinds)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayExt.kt new file mode 100644 index 0000000000..c3238ee5de --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayExt.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.kinds + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.core.anyTagged +import com.vitorpamplona.quartz.nip01Core.core.forEachTagged + +fun TagArray.forEachKind(onEach: (eventId: HexKey) -> Unit) = this.forEachTagged(KindTag.TAG_NAME, onEach) + +fun TagArray.anyKind(onEach: (str: String) -> Boolean) = this.anyTagged(KindTag.TAG_NAME, onEach) + +fun TagArray.hasKind() = this.any(KindTag::match) + +fun TagArray.kinds() = this.mapNotNull(KindTag::parse) + +fun TagArray.isTaggedKind(kind: Int): Boolean = this.any(KindTag::isTagged, kind.toString()) + +fun TagArray.isTaggedKinds(kinds: Set) = this.any(KindTag::isIn, kinds.mapTo(HashSet()) { it.toString() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EntityExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EntityExt.kt new file mode 100644 index 0000000000..d44a1e696a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EntityExt.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.people + +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +fun NProfile.toQuoteTag() = PTag(hex, relay.firstOrNull()) + +fun NPub.toQuoteTag() = PTag(hex, null) + +fun NProfile.toQuoteTagArray() = PTag.assemble(hex, relay.firstOrNull()) + +fun NPub.toQuoteTagArray() = PTag.assemble(hex, null) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EventExt.kt index a573de7737..6248f93539 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EventExt.kt @@ -21,18 +21,17 @@ package com.vitorpamplona.quartz.nip01Core.tags.people import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent -import com.vitorpamplona.quartz.nip01Core.core.isAnyTagged -import com.vitorpamplona.quartz.nip01Core.core.isTagged -import com.vitorpamplona.quartz.nip01Core.core.mapValues -fun Event.isTaggedUser(idHex: String) = tags.isTagged("p", idHex) +fun Event.isTaggedUser(idHex: String) = tags.isTaggedUser(idHex) -fun Event.isTaggedUsers(idHexes: Set) = tags.isAnyTagged("p", idHexes) +fun Event.isTaggedUsers(idHexes: Set) = tags.isTaggedUsers(idHexes) -fun Event.taggedUsers() = tags.mapValues("p") +fun Event.taggedUsers() = tags.taggedUsers() -fun Event.firstTaggedUser() = tags.firstTagValue("p") +fun Event.taggedUserIds() = tags.taggedUserIds() -fun Event.hasAnyTaggedUser() = tags.hasTagWithContent("p") +fun Event.firstTaggedUser() = tags.firstTaggedUser() + +fun Event.firstTaggedUserId() = tags.firstTaggedUserId() + +fun Event.hasAnyTaggedUser() = tags.hasAnyTaggedUser() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt new file mode 100644 index 0000000000..55a2270ac7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.people + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +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.pointerSizeInBytes + +@Immutable +data class PTag( + override val pubKey: HexKey, + override val relayHint: String? = null, +) : PubKeyReferenceTag { + fun countMemory(): Long = + 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) + pubKey.bytesUsedInMemory() + + (relayHint?.bytesUsedInMemory() ?: 0) + + fun toNProfile(): String = NProfile.create(pubKey, relayHint?.let { listOf(it) } ?: emptyList()) + + fun toNPub(): String = pubKey.hexToByteArray().toNpub() + + fun toTagArray() = assemble(pubKey, relayHint) + + companion object { + const val TAG_NAME = "p" + const val TAG_SIZE = 2 + + fun isTagged( + tag: Array, + key: HexKey, + ): Boolean = tag.size >= 2 && tag[0] == TAG_NAME && tag[1] == key + + @JvmStatic + fun parse(tag: Tag): PTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[1].length != 64) return null + return PTag(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseKey(tag: Array): HexKey? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[1].length != 64) { + Log.w("PTag", "Invalid `$TAG_NAME` value ${tag.joinToString(", ")}") + return null + } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length != 64 || tag[2].isEmpty()) return null + return PubKeyHint(tag[1], tag[2]) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: String?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt new file mode 100644 index 0000000000..7e36107d4a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.people + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +interface PubKeyReferenceTag { + val pubKey: HexKey + val relayHint: String? +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..f3728d5da5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.people + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.pTag( + pubkey: HexKey, + relayHint: String? = null, +) = add(PTag.assemble(pubkey, relayHint)) + +fun TagArrayBuilder.pTagIds(tag: Set) = addAll(tag.map { PTag.assemble(it, null) }) + +fun TagArrayBuilder.pTag(tag: PTag) = add(tag.toTagArray()) + +fun TagArrayBuilder.pTags(tag: List) = addAll(tag.map { it.toTagArray() }) + +fun TagArrayBuilder.pTags(tag: Set) = addAll(tag.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayExt.kt index c21f06b529..e8d099ba9f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayExt.kt @@ -21,18 +21,21 @@ package com.vitorpamplona.quartz.nip01Core.tags.people import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent import com.vitorpamplona.quartz.nip01Core.core.isAnyTagged import com.vitorpamplona.quartz.nip01Core.core.isTagged -import com.vitorpamplona.quartz.nip01Core.core.mapValues +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.TAG_NAME -fun TagArray.isTaggedUser(idHex: String) = this.isTagged("p", idHex) +fun TagArray.isTaggedUser(idHex: String) = this.isTagged(TAG_NAME, idHex) -fun TagArray.isTaggedUsers(idHexes: Set) = this.isAnyTagged("p", idHexes) +fun TagArray.isTaggedUsers(idHexes: Set) = this.isAnyTagged(TAG_NAME, idHexes) -fun TagArray.taggedUsers() = this.mapValues("p") +fun TagArray.taggedUsers() = this.mapNotNull(PTag::parse) -fun TagArray.firstTaggedUser() = this.firstTagValue("p") +fun TagArray.firstTaggedUser() = this.firstNotNullOfOrNull(PTag::parse) -fun TagArray.hasAnyTaggedUser() = this.hasTagWithContent("p") +fun TagArray.taggedUserIds() = this.mapNotNull(PTag::parseKey) + +fun TagArray.firstTaggedUserId() = this.firstNotNullOfOrNull(PTag::parseKey) + +fun TagArray.hasAnyTaggedUser() = this.hasTagWithContent(TAG_NAME) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/EventExt.kt new file mode 100644 index 0000000000..679879c915 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/EventExt.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.references + +import com.vitorpamplona.quartz.nip01Core.core.Event + +fun Event.hasReferenceTag() = tags.hasReferences() + +fun Event.references() = tags.references() + +fun Event.isTaggedReference(reference: String) = tags.isTaggedReference(reference) + +fun Event.isTaggedReferences(references: Set) = tags.isTaggedReferences(references) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt new file mode 100644 index 0000000000..8c07af9ffa --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.references + +import com.vitorpamplona.quartz.nip96FileStorage.HttpUrlFormatter + +class ReferenceTag { + companion object { + const val TAG_NAME = "r" + const val TAG_SIZE = 2 + + @JvmStatic + fun isTagged( + tag: Array, + reference: String, + ): Boolean = tag.size >= 2 && tag[0] == TAG_NAME && tag[1] == reference + + @JvmStatic + fun isIn( + tag: Array, + references: Set, + ): Boolean = tag.size >= 2 && tag[0] == TAG_NAME && tag[1] in references + + @JvmStatic + fun hasReference(tag: Array): Boolean { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return false + return tag[1].isNotEmpty() + } + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url)) + + @JvmStatic + fun assemble(urls: List): List> = urls.mapTo(HashSet()) { HttpUrlFormatter.normalize(it) }.map { arrayOf(TAG_NAME, it) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ae58cf4cac --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.references + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.reference(url: String) = add(ReferenceTag.assemble(url)) + +fun TagArrayBuilder.references(urls: List) = addAll(ReferenceTag.assemble(urls)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayExt.kt new file mode 100644 index 0000000000..4a3fb5427f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayExt.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.references + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.any + +fun TagArray.hasReferences() = this.any(ReferenceTag::hasReference) + +fun TagArray.references() = this.mapNotNull(ReferenceTag::parse) + +fun TagArray.isTaggedReference(reference: String) = this.any(ReferenceTag::isTagged, reference) + +fun TagArray.isTaggedReferences(references: Set) = this.any(ReferenceTag::isIn, references) 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 293170f69f..a5d995afbc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -20,33 +20,26 @@ */ package com.vitorpamplona.quartz.nip02FollowList -import android.util.Log -import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip01Core.toHexKey -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey -import com.vitorpamplona.quartz.nip19Bech32.parse -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip02FollowList.tags.AddressFollowTag +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils -@Immutable data class Contact( - val pubKeyHex: String, - val relayUri: String?, -) - @Stable class ContactListEvent( id: HexKey, @@ -55,70 +48,34 @@ class ContactListEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + PubKeyHintProvider { + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun pubKeyHints() = tags.mapNotNull(ContactTag::parseAsHint) + /** * Returns a list of p-tags that are verified as hex keys. */ - fun verifiedFollowKeySet(): Set = - tags.mapNotNullTo(mutableSetOf()) { - if (it.size > 1 && it[0] == "p") { - try { - decodePublicKey(it[1]).toHexKey() - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse p-tag $it in the contact list of $pubKey with id $id", e) - null - } - } else { - null - } - } + fun verifiedFollowKeySet(): Set = tags.mapNotNullTo(HashSet(), ContactTag::parseValidKey) /** * Returns a list of a-tags that are verified as correct. */ - fun verifiedFollowAddressSet(): Set = - tags - .mapNotNullTo(mutableSetOf()) { - if (it.size > 1 && it[0] == "a") { - ATag.parse(it[1], null)?.toTag() - } else { - null - } - } + fun verifiedFollowAddressSet(): Set = tags.mapNotNullTo(HashSet(), AddressFollowTag::parseValidAddress) - fun unverifiedFollowKeySet() = tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { it.getOrNull(1) } + fun unverifiedFollowKeySet() = tags.mapNotNull(ContactTag::parseKey) - fun unverifiedFollowTagSet() = tags.filter { it.size > 1 && it[0] == "t" }.mapNotNull { it.getOrNull(1) } + fun unverifiedFollowTagSet() = tags.hashtags() - fun countFollowTags() = tags.count { it.size > 1 && it[0] == "t" } + fun countFollowTags() = tags.countHashtags() - fun follows() = - tags.mapNotNull { - try { - if (it.size > 1 && it[0] == "p") { - Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2)) - } else { - null - } - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) - null - } - } + fun follows() = tags.mapNotNull(ContactTag::parseValid) fun followsTags() = hashtags() - fun relays(): Map? = - try { - if (content.isNotEmpty()) { - EventMapper.mapper.readValue>(content) - } else { - null - } - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e) - null - } + fun relays(): Map? = RelaySet.parse(content) companion object { const val KIND = 3 @@ -127,7 +84,7 @@ class ContactListEvent( fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:" fun createFromScratch( - followUsers: List = emptyList(), + followUsers: List = emptyList(), followTags: List = emptyList(), followGeohashes: List = emptyList(), followCommunities: List = emptyList(), @@ -136,18 +93,11 @@ class ContactListEvent( signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), ): ContactListEvent? { - val content = - if (relayUse != null) { - EventMapper.mapper.writeValueAsString(relayUse) - } else { - "" - } + val content = relayUse?.let { RelaySet.assemble(it) } ?: "" val tags = - listOf(AltTagSerializer.toTagArray(ALT)) + - followUsers.map { - listOfNotNull("p", it.pubKeyHex, it.relayUri).toTypedArray() - } + + listOf(AltTag.assemble(ALT)) + + followUsers.map { it.toTagArray() } + followTags.map { arrayOf("t", it) } + followEvents.map { arrayOf("e", it) } + followCommunities.map { it.toATagArray() } + @@ -157,7 +107,7 @@ class ContactListEvent( } fun createFromScratch( - followUsers: List, + followUsers: List, followTags: List, followGeohashes: List, followCommunities: List, @@ -167,21 +117,10 @@ class ContactListEvent( createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit, ) { - val content = - if (relayUse != null) { - EventMapper.mapper.writeValueAsString(relayUse) - } else { - "" - } + val content = relayUse?.let { RelaySet.assemble(it) } ?: "" val tags = - followUsers.map { - if (it.relayUri != null) { - arrayOf("p", it.pubKeyHex, it.relayUri) - } else { - arrayOf("p", it.pubKeyHex) - } - } + + followUsers.map { it.toTagArray() } + followTags.map { arrayOf("t", it) } + followEvents.map { arrayOf("e", it) } + followCommunities.map { it.toATagArray() } + @@ -387,12 +326,7 @@ class ContactListEvent( createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit, ) { - val content = - if (relayUse != null) { - EventMapper.mapper.writeValueAsString(relayUse) - } else { - "" - } + val content = relayUse?.let { RelaySet.assemble(it) } ?: "" return create( content = content, @@ -414,23 +348,10 @@ class ContactListEvent( if (tags.any { it.size > 1 && it[0] == "alt" }) { tags } else { - tags + AltTagSerializer.toTagArray(ALT) + tags + AltTag.assemble(ALT) } signer.sign(createdAt, KIND, newTags, content, onReady) } } - - data class ReadWrite( - val read: Boolean, - val write: Boolean, - ) } - -@Stable class ImmutableListOfLists( - val lists: Array>, -) - -val EmptyTagList = ImmutableListOfLists(emptyArray()) - -fun Array>.toImmutableListOfLists(): ImmutableListOfLists = ImmutableListOfLists(this) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ImmutableListOfLists.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ImmutableListOfLists.kt new file mode 100644 index 0000000000..513481ad0c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ImmutableListOfLists.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip02FollowList + +import androidx.compose.runtime.Stable + +@Stable +class ImmutableListOfLists( + val lists: Array>, +) + +val EmptyTagList = ImmutableListOfLists(emptyArray()) + +fun Array>.toImmutableListOfLists(): ImmutableListOfLists = ImmutableListOfLists(this) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt new file mode 100644 index 0000000000..f25233d448 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip02FollowList + +import android.util.Log +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper + +data class ReadWrite( + val read: Boolean, + val write: Boolean, +) + +class RelaySet { + companion object { + fun assemble(relayUse: Map): String = EventMapper.mapper.writeValueAsString(relayUse) + + fun parse(content: String): Map? = + try { + if (content.isNotEmpty()) { + EventMapper.mapper.readValue>(content) + } else { + null + } + } catch (e: Exception) { + Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e) + null + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt new file mode 100644 index 0000000000..cfe10b3a0c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip02FollowList.tags + +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag + +typealias AddressFollowTag = ATag diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt new file mode 100644 index 0000000000..eab66cef33 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip02FollowList.tags + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +data class ContactTag( + val pubKey: HexKey, +) { + var relayUri: String? = null + var petname: String? = null + + constructor( + pubKey: HexKey, + relayHint: String?, + petname: String?, + ) : this(pubKey) { + this.relayUri = relayHint + this.petname = petname + } + + fun countMemory(): Long = + 3 * pointerSizeInBytes + + pubKey.bytesUsedInMemory() + + (relayUri?.bytesUsedInMemory() ?: 0) + + (petname?.bytesUsedInMemory() ?: 0) + + fun toTagArray() = assemble(pubKey, relayUri, petname) + + companion object { + const val TAG_NAME = "p" + const val TAG_SIZE = 2 + + @JvmStatic + fun isTagged(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): ContactTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME || tag[1].length != 64) return null + return ContactTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + } + + @JvmStatic + fun parseValid(tag: Array): ContactTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME || tag[1].length != 64) return null + return try { + ContactTag(decodePublicKey(tag[1]).toHexKey(), tag.getOrNull(2), tag.getOrNull(3)) + } catch (e: Exception) { + Log.w("ContactTag", "Can't parse contact list p-tag ${tag.joinToString(", ")}", e) + null + } + } + + @JvmStatic + fun parseKey(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME || tag[1].length != 64) return null + return tag[1] + } + + @JvmStatic + fun parseValidKey(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME || tag[1].length != 64) return null + return try { + decodePublicKey(tag[1]).toHexKey() + } catch (e: Exception) { + Log.w("ContactListEvent", "Can't parse contact list pubkey ${tag.joinToString(", ")}", e) + null + } + } + + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length != 64 || tag[2].isEmpty()) return null + return PubKeyHint(tag[1], tag[2]) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayUri: String? = null, + petname: String? = null, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayUri, petname) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt index bfac829eca..bcd82b3fb2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt @@ -20,24 +20,17 @@ */ package com.vitorpamplona.quartz.nip03Timestamp -import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockstreamExplorer -import com.vitorpamplona.quartz.nip03Timestamp.ots.CalendarPureJavaBuilder -import com.vitorpamplona.quartz.nip03Timestamp.ots.DetachedTimestampFile -import com.vitorpamplona.quartz.nip03Timestamp.ots.Hash -import com.vitorpamplona.quartz.nip03Timestamp.ots.OpenTimestamps -import com.vitorpamplona.quartz.nip03Timestamp.ots.VerifyResult -import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException -import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpSHA256 -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip03Timestamp.tags.TargetEventTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import kotlinx.coroutines.CancellationException import java.util.Base64 @Immutable @@ -48,140 +41,66 @@ class OtsEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient - var verification: VerificationState = VerificationState.NotStarted - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + Long.SIZE_BYTES // verifiedTime +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = tags.mapNotNull(TargetEventTag::parseAsHint) override fun isContentEncoded() = true - fun digestEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun digestEventId() = tags.firstNotNullOfOrNull(TargetEventTag::parseId) - fun digest() = digestEvent()?.hexToByteArray() + fun otsByteArray(): ByteArray = decodeOtsState(content) - fun otsByteArray(): ByteArray = Base64.getDecoder().decode(content) + fun cacheVerify(): VerificationState = VerificationStateCache.cacheVerify(this) - fun cacheVerify(): VerificationState = - when (val verif = verification) { - is VerificationState.Verified -> verif - is VerificationState.NotStarted -> verifyState().also { verification = it } - is VerificationState.NetworkError -> { - // try again in 5 mins - if (verif.time < TimeUtils.fiveMinutesAgo()) { - verifyState().also { verification = it } - } else { - verif - } - } - is VerificationState.Error -> verif - } - - fun verifyState(): VerificationState = digestEvent()?.let { verify(otsByteArray(), it) } ?: VerificationState.Error("Digest Not found") + fun verifyState(): VerificationState = digestEventId()?.let { verify(otsByteArray(), it) } ?: VerificationState.Error("Digest Not found") fun verify(): Long? = (verifyState() as? VerificationState.Verified)?.verifiedTime - fun info(): String { - val detachedOts = DetachedTimestampFile.deserialize(otsByteArray()) - return otsInstance.info(detachedOts) - } - companion object { const val KIND = 1040 const val ALT = "Opentimestamps Attestation" - var otsInstance = - OpenTimestamps( - BlockstreamExplorer(), - CalendarPureJavaBuilder(), - ) - - fun stamp(eventId: HexKey): String { - val hash = - Hash( - eventId.hexToByteArray(), - OpSHA256._TAG, - ) - val file = DetachedTimestampFile.from(hash) - val timestamp = otsInstance.stamp(file) - val detachedToSerialize = - DetachedTimestampFile( - hash.getOp(), - timestamp, - ) - return Base64.getEncoder().encodeToString(detachedToSerialize.serialize()) - } + fun stamp(eventId: HexKey) = OtsResolver.stamp(eventId.hexToByteArray()) fun upgrade( - otsFile: String, + otsState: ByteArray, eventId: HexKey, - ): String { - val detachedOts = DetachedTimestampFile.deserialize(Base64.getDecoder().decode(otsFile)) - - return if (otsInstance.upgrade(detachedOts)) { - // if the change is now verifiable. - if (verify(detachedOts, eventId) is VerificationState.Verified) { - Base64.getEncoder().encodeToString(detachedOts.serialize()) - } else { - otsFile - } - } else { - otsFile - } - } + ) = OtsResolver.upgrade(otsState, eventId.hexToByteArray()) fun verify( - otsFile: String, + otsState: ByteArray, eventId: HexKey, - ): VerificationState = verify(Base64.getDecoder().decode(otsFile), eventId) + ) = OtsResolver.verify(otsState, eventId.hexToByteArray()) - fun verify( - otsFile: ByteArray, - eventId: HexKey, - ): VerificationState = verify(DetachedTimestampFile.deserialize(otsFile), eventId) + fun encodeOtsState(otsState: ByteArray) = Base64.getEncoder().encodeToString(otsState) - fun verify( - detachedOts: DetachedTimestampFile, - eventId: HexKey, - ): VerificationState { - try { - val result = otsInstance.verify(detachedOts, eventId.hexToByteArray()) - if (result == null || result.isEmpty()) { - return VerificationState.Error("Verification hashmap is empty") - } else { - val time = result.get(VerifyResult.Chains.BITCOIN)?.timestamp - return if (time != null) { - VerificationState.Verified(time) - } else { - VerificationState.Error("Does not include a Bitcoin verification") - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("OpenTimeStamps", "Failed to verify", e) - return if (e is UrlException) { - VerificationState.NetworkError(e.message ?: e.cause?.message ?: "Failed to verify") - } else { - VerificationState.Error(e.message ?: e.cause?.message ?: "Failed to verify") - } - } - } + fun decodeOtsState(content: String) = Base64.getDecoder().decode(content) - fun create( + fun build( eventId: HexKey, - otsFileBase64: String, - signer: NostrSigner, + otsState: ByteArray, createdAt: Long = TimeUtils.now(), - onReady: (OtsEvent) -> Unit, - ) { - val tags = - arrayOf( - arrayOf("e", eventId), - AltTagSerializer.toTagArray(ALT), - ) - signer.sign(createdAt, KIND, tags, otsFileBase64, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, encodeOtsState(otsState), createdAt) { + alt(ALT) + targetEvent(TargetEventTag(eventId)) + + initializer() + } + + fun build( + event: EventHintBundle, + otsState: ByteArray, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, encodeOtsState(otsState), createdAt) { + alt(ALT) + + targetEvent(event.toETag()) + targetKind(event.event.kind) + + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt new file mode 100644 index 0000000000..384c46e599 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip03Timestamp + +import android.util.Log +import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockstreamExplorer +import com.vitorpamplona.quartz.nip03Timestamp.ots.CalendarPureJavaBuilder +import com.vitorpamplona.quartz.nip03Timestamp.ots.DetachedTimestampFile +import com.vitorpamplona.quartz.nip03Timestamp.ots.Hash +import com.vitorpamplona.quartz.nip03Timestamp.ots.OpenTimestamps +import com.vitorpamplona.quartz.nip03Timestamp.ots.VerifyResult +import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpSHA256 +import kotlinx.coroutines.CancellationException + +object OtsResolver { + // default config + var ots = + OpenTimestamps( + BlockstreamExplorer(), + CalendarPureJavaBuilder(), + ) + + fun info(otsState: ByteArray): String = ots.info(DetachedTimestampFile.deserialize(otsState)) + + fun stamp(data: ByteArray): ByteArray { + val hash = Hash(data, OpSHA256._TAG) + val file = DetachedTimestampFile.from(hash) + val timestamp = ots.stamp(file) + val detachedToSerialize = DetachedTimestampFile(hash.getOp(), timestamp) + return detachedToSerialize.serialize() + } + + fun upgrade( + otsState: ByteArray, + data: ByteArray, + ): ByteArray? { + val detachedOts = DetachedTimestampFile.deserialize(otsState) + + return if (ots.upgrade(detachedOts)) { + // if the change is now verifiable. + if (verify(detachedOts, data) is VerificationState.Verified) { + detachedOts.serialize() + } else { + null + } + } else { + null + } + } + + fun verify( + otsFile: ByteArray, + data: ByteArray, + ): VerificationState = verify(DetachedTimestampFile.deserialize(otsFile), data) + + fun verify( + detachedOts: DetachedTimestampFile, + data: ByteArray, + ): VerificationState { + try { + val result = ots.verify(detachedOts, data) + if (result == null || result.isEmpty()) { + return VerificationState.Error("Verification hashmap is empty") + } else { + val time = result.get(VerifyResult.Chains.BITCOIN)?.timestamp + return if (time != null) { + VerificationState.Verified(time) + } else { + VerificationState.Error("Does not include a Bitcoin verification") + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("OpenTimeStamps", "Failed to verify", e) + return if (e is UrlException) { + VerificationState.NetworkError(e.message ?: e.cause?.message ?: "Failed to verify") + } else { + VerificationState.Error(e.message ?: e.cause?.message ?: "Failed to verify") + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..c42a2296a7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/TagArrayBuilderExt.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip03Timestamp + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip03Timestamp.tags.TargetEventKindTag +import com.vitorpamplona.quartz.nip03Timestamp.tags.TargetEventTag + +fun TagArrayBuilder.targetEvent(tag: TargetEventTag) = add(tag.toTagArray()) + +fun TagArrayBuilder.targetEvents(tag: List) = addAll(tag.map { it.toTagArray() }) + +fun TagArrayBuilder.targetKind(kind: Int) = add(TargetEventKindTag.assemble(kind)) + +fun TagArrayBuilder.targetKinds(kinds: List) = addAll(TargetEventKindTag.assemble(kinds)) + +fun TagArrayBuilder.targetKinds(kinds: Set) = addAll(TargetEventKindTag.assemble(kinds)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt new file mode 100644 index 0000000000..98d7ce76a0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip03Timestamp + +import android.util.LruCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils + +object VerificationStateCache { + private val cache = LruCache(200) + + fun cacheVerify(event: OtsEvent): VerificationState = + when (val verif = cache[event.id]) { + is VerificationState.Verified -> verif + is VerificationState.NetworkError -> { + // try again in 5 mins + if (verif.time < TimeUtils.fiveMinutesAgo()) { + event.verifyState().also { cache.put(event.id, it) } + } else { + verif + } + } + is VerificationState.Error -> verif + else -> event.verifyState().also { cache.put(event.id, it) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventKindTag.kt new file mode 100644 index 0000000000..6f2955a3d9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventKindTag.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip03Timestamp.tags + +import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag + +typealias TargetEventKindTag = KindTag diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventTag.kt similarity index 88% rename from quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomExt.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventTag.kt index 333b62a679..10b3270b73 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventTag.kt @@ -18,8 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.utils +package com.vitorpamplona.quartz.nip03Timestamp.tags -import java.security.SecureRandom +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -fun SecureRandom.nextBytes(size: Int) = ByteArray(size).also { nextBytes(it) } +typealias TargetEventTag = ETag diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/Nip04.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/Nip04.kt deleted file mode 100644 index de2fa6a48a..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/Nip04.kt +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip04Dm - -import android.util.Log -import com.vitorpamplona.quartz.nip44Encryption.SharedKeyCache -import com.vitorpamplona.quartz.utils.Hex -import fr.acinq.secp256k1.Secp256k1 -import java.security.SecureRandom -import java.util.Base64 -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -class Nip04( - val secp256k1: Secp256k1, - val random: SecureRandom, -) { - private val sharedKeyCache = SharedKeyCache() - private val h02 = Hex.decode("02") - - fun clearCache() { - sharedKeyCache.clearCache() - } - - fun encrypt( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String = encrypt(msg, getSharedSecret(privateKey, pubKey)).encodeToNIP04() - - fun encrypt( - msg: String, - sharedSecret: ByteArray, - ): EncryptedInfo { - val iv = ByteArray(16) - random.nextBytes(iv) - - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) - // val ivBase64 = Base64.getEncoder().encodeToString(iv) - val encryptedMsg = cipher.doFinal(msg.toByteArray()) - // val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg) - return EncryptedInfo(encryptedMsg, iv) - } - - fun decrypt( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(msg, sharedSecret) - } - - fun decrypt( - encryptedInfo: EncryptedInfo, - privateKey: ByteArray, - pubKey: ByteArray, - ): String { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(encryptedInfo.ciphertext, encryptedInfo.nonce, sharedSecret) - } - - fun decrypt( - msg: String, - sharedSecret: ByteArray, - ): String { - val decoded = EncryptedInfo.decodeFromNIP04(msg) - check(decoded != null) { "Unable to decode msg $msg as NIP04" } - return decrypt(decoded.ciphertext, decoded.nonce, sharedSecret) - } - - fun decrypt( - cipher: String, - nonce: String, - sharedSecret: ByteArray, - ): String { - val iv = Base64.getDecoder().decode(nonce) - val encryptedMsg = Base64.getDecoder().decode(cipher) - return decrypt(encryptedMsg, iv, sharedSecret) - } - - fun decrypt( - encryptedMsg: ByteArray, - iv: ByteArray, - sharedSecret: ByteArray, - ): String { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) - return String(cipher.doFinal(encryptedMsg)) - } - - fun getSharedSecret( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - val preComputed = sharedKeyCache.get(privateKey, pubKey) - if (preComputed != null) return preComputed - - val computed = computeSharedSecret(privateKey, pubKey) - sharedKeyCache.add(privateKey, pubKey, computed) - return computed - } - - /** @return 32B shared secret */ - fun computeSharedSecret( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) - - companion object { - fun isNIP04(encoded: String) = EncryptedInfo.isNIP04(encoded) - } - - class EncryptedInfo( - val ciphertext: ByteArray, - val nonce: ByteArray, - ) { - companion object { - const val V: Int = 0 - - fun decodePayload(payload: String): EncryptedInfo? { - return try { - val byteArray = Base64.getDecoder().decode(payload) - check(byteArray[0].toInt() == V) - return EncryptedInfo( - nonce = byteArray.copyOfRange(1, 25), - ciphertext = byteArray.copyOfRange(25, byteArray.size), - ) - } catch (e: Exception) { - Log.w("NIP04", "Unable to Parse encrypted payload: $payload") - null - } - } - - fun isNIP04(encoded: String): Boolean { - // cleaning up some bug from some client. - val cleanedUp = encoded.removeSuffix("-null") - - val l = cleanedUp.length - if (l < 28) return false - return cleanedUp[l - 28] == '?' && - cleanedUp[l - 27] == 'i' && - cleanedUp[l - 26] == 'v' && - cleanedUp[l - 25] == '=' - } - - fun decodeFromNIP04(payload: String): EncryptedInfo? = - try { - // cleaning up some bug from some client. - val parts = payload.removeSuffix("-null").split("?iv=") - EncryptedInfo( - ciphertext = Base64.getDecoder().decode(parts[0]), - nonce = Base64.getDecoder().decode(parts[1]), - ) - } catch (e: Exception) { - Log.w("NIP04", "Unable to Parse encrypted payload: $payload") - null - } - } - - fun encodePayload(): String = - Base64 - .getEncoder() - .encodeToString( - byteArrayOf(V.toByte()) + nonce + ciphertext, - ) - - fun encodeToNIP04(): String { - val nonce = Base64.getEncoder().encodeToString(nonce) - val ciphertext = Base64.getEncoder().encodeToString(ciphertext) - return "$ciphertext?iv=$nonce" - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt new file mode 100644 index 0000000000..0a2c9b04f7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip04Dm.crypto + +import android.util.Log +import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher +import com.vitorpamplona.quartz.utils.RandomInstance +import java.security.GeneralSecurityException +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class AESCBC( + val keyBytes: ByteArray = RandomInstance.bytes(32), + val iv: ByteArray = RandomInstance.bytes(16), +) : NostrCipher { + private fun newCipher() = Cipher.getInstance("AES/CBC/PKCS5Padding") + + private fun keySpec() = SecretKeySpec(keyBytes, "AES") + + private fun param() = IvParameterSpec(iv) + + override fun name() = NAME + + override fun encrypt(bytesToEncrypt: ByteArray): ByteArray = + with(newCipher()) { + init(Cipher.ENCRYPT_MODE, keySpec(), param()) + doFinal(bytesToEncrypt) + } + + override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = + with(newCipher()) { + init(Cipher.DECRYPT_MODE, keySpec(), param()) + doFinal(bytesToDecrypt) + } + + override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + decrypt(bytesToDecrypt) + } catch (e: GeneralSecurityException) { + Log.w("AESCBC", "Failed to decrypt", e) + null + } + + companion object { + const val NAME = "aes-cbc" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt new file mode 100644 index 0000000000..067185f209 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip04Dm.crypto + +import android.util.Log +import java.util.Base64 + +class EncryptedInfo( + val ciphertext: ByteArray, + val nonce: ByteArray, +) { + fun encodeToNIP04() = encode(ciphertext, nonce) + + companion object { + const val V: Int = 0 + + fun isNIP04(encoded: String): Boolean { + // cleaning up some bug from some client. + val cleanedUp = encoded.removeSuffix("-null") + + val l = cleanedUp.length + if (l < 28) return false + return cleanedUp[l - 28] == '?' && + cleanedUp[l - 27] == 'i' && + cleanedUp[l - 26] == 'v' && + cleanedUp[l - 25] == '=' + } + + fun encode( + ciphertext: ByteArray, + nonce: ByteArray, + ): String { + val nonceB64 = Base64.getEncoder().encodeToString(nonce) + val ciphertextB64 = Base64.getEncoder().encodeToString(ciphertext) + return "$ciphertextB64?iv=$nonceB64" + } + + fun decode(payload: String): EncryptedInfo? = + try { + // cleaning up some bug from some client. + val parts = payload.removeSuffix("-null").split("?iv=") + EncryptedInfo( + ciphertext = Base64.getDecoder().decode(parts[0]), + nonce = Base64.getDecoder().decode(parts[1]), + ) + } catch (e: Exception) { + Log.w("NIP04", "Unable to Parse encrypted payload: $payload") + null + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Encryption.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Encryption.kt new file mode 100644 index 0000000000..3303c9f2aa --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Encryption.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip04Dm.crypto + +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.Secp256k1Instance + +class Encryption { + fun encrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String = encrypt(msg, computeSharedSecret(privateKey, pubKey)) + + fun decrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String = decrypt(msg, computeSharedSecret(privateKey, pubKey)) + + fun encrypt( + msg: String, + sharedSecret: ByteArray, + ): String = encryptToEncoder(msg, sharedSecret).encodeToNIP04() + + fun encryptToEncoder( + msg: String, + sharedSecret: ByteArray, + ): EncryptedInfo { + val iv = RandomInstance.bytes(16) + return EncryptedInfo(AESCBC(sharedSecret, iv).encrypt(msg.toByteArray()), iv) + } + + fun decrypt( + msg: String, + sharedSecret: ByteArray, + ): String { + val decoded = EncryptedInfo.decode(msg) + check(decoded != null) { "Unable to decode msg $msg as NIP04" } + return decrypt(decoded, sharedSecret) + } + + fun decrypt( + msg: EncryptedInfo, + sharedSecret: ByteArray, + ): String = decrypt(msg.ciphertext, msg.nonce, sharedSecret) + + fun decrypt( + encryptedMsg: ByteArray, + iv: ByteArray, + sharedSecret: ByteArray, + ): String = String(AESCBC(sharedSecret, iv).decrypt(encryptedMsg)) + + fun computeSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray = Secp256k1Instance.pubKeyTweakMulCompact(pubKey, privateKey) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Nip04.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Nip04.kt new file mode 100644 index 0000000000..a4d86f7f77 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Nip04.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip04Dm.crypto + +import com.vitorpamplona.quartz.nip44Encryption.SharedKeyCache + +object Nip04 { + private val sharedKeyCache = SharedKeyCache() + + private val nip04 = Encryption() + + fun clearCache() { + sharedKeyCache.clearCache() + } + + fun getSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + val preComputed = sharedKeyCache.get(privateKey, pubKey) + if (preComputed != null) return preComputed + + val computed = nip04.computeSharedSecret(privateKey, pubKey) + sharedKeyCache.add(privateKey, pubKey, computed) + return computed + } + + fun encrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String = nip04.encrypt(msg, getSharedSecret(privateKey, pubKey)) + + fun decrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String = nip04.decrypt(msg, getSharedSecret(privateKey, pubKey)) + + fun decrypt( + msg: EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String = nip04.decrypt(msg, getSharedSecret(privateKey, pubKey)) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt similarity index 61% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDmEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt index 84bb7302be..3e31478a03 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt @@ -18,21 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip04Dm +package com.vitorpamplona.quartz.nip04Dm.messages import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip17Dm.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.ChatroomKeyable -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils @@ -63,7 +64,7 @@ class PrivateDmEvent( * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used for * initial messages. */ - private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + private fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull() @@ -86,9 +87,9 @@ class PrivateDmEvent( * Nip-18 messages should refer to other events by inline references in the content like * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). */ - fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun replyTo() = tags.firstNotNullOfOrNull(MarkedETag::parseId) - fun with(pubkeyHex: String): Boolean = pubkeyHex == pubKey || tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex } + fun with(pubkeyHex: HexKey): Boolean = pubkeyHex == pubKey || tags.any(PTag::isTagged, pubkeyHex) fun cachedContentFor(signer: NostrSigner): String? = decryptedContent[signer.pubKey] @@ -120,62 +121,33 @@ class PrivateDmEvent( const val ALT = "Private Message" const val NIP_18_ADVERTISEMENT = "[//]: # (nip18)\n" - fun create( - recipientPubKey: HexKey, + fun prepareMessageToEncrypt( msg: String, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - publishedRecipientPubKey: HexKey? = null, - advertiseNip18: Boolean = true, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, imetas: List? = null, - isDraft: Boolean, - onReady: (PrivateDmEvent) -> Unit, - ) { + advertiseNip18: Boolean = true, + ): String { var message = msg imetas?.forEach { message = message.replace(it.url, Nip54InlineMetadata().createUrl(it.url, it.properties)) } - message = - if (advertiseNip18) { - NIP_18_ADVERTISEMENT + message - } else { - message - } - - val tags = mutableListOf>() - publishedRecipientPubKey?.let { tags.add(arrayOf("p", publishedRecipientPubKey)) } - replyTos?.forEach { tags.add(arrayOf("e", it, "", "reply")) } - mentions?.forEach { tags.add(arrayOf("p", it)) } - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) + return if (advertiseNip18) { + NIP_18_ADVERTISEMENT + message + } else { + message } + } - geohash?.let { tags.addAll(geohashMipMap(it)) } - /* Privacy issue: DO NOT ADD THESE TO THE TAGS. - imetas?.forEach { - tags.add(Nip92MediaAttachments.createTag(it)) - } - */ + fun build( + to: PTag, + encryptedMessage: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, encryptedMessage, createdAt) { + alt(ALT) + pTag(to) - tags.add(AltTagSerializer.toTagArray(ALT)) - - signer.nip04Encrypt(message, recipientPubKey) { content -> - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), content, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..b4abcce1a0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip04Dm.messages + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag + +fun TagArrayBuilder.reply(tag: EventReference) = add(MarkedETag.assemble(tag.eventId, tag.relayHint, MarkedETag.MARKER.REPLY, tag.author)) + +fun TagArrayBuilder.reply(tag: EventHintBundle) = add(tag.toMarkedETag(MarkedETag.MARKER.REPLY).toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt index 88fc1dcdcf..c093a2c453 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt @@ -20,16 +20,13 @@ */ package com.vitorpamplona.quartz.nip06KeyDerivation -import fr.acinq.secp256k1.Secp256k1 -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.hmac512 /* Simplified from: https://github.com/ACINQ/bitcoin-kmp/ */ -class Bip32SeedDerivation( - val secp256k1: Secp256k1, -) { +class Bip32SeedDerivation { class ExtendedPrivateKey( val secretkeybytes: ByteArray, val chaincode: ByteArray, @@ -46,24 +43,6 @@ class Bip32SeedDerivation( return ExtendedPrivateKey(il, ir) } - fun hmac512( - key: ByteArray, - data: ByteArray, - ): ByteArray { - val mac = Mac.getInstance("HmacSHA512") - mac.init(SecretKeySpec(key, "HmacSHA512")) - return mac.doFinal(data) - } - - fun isPrivKeyValid(il: ByteArray): Boolean = secp256k1.secKeyVerify(il) - - fun pubkeyCreateBitcoin(privKey: ByteArray) = secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)) - - fun sum( - first: ByteArray, - second: ByteArray, - ): ByteArray = secp256k1.privKeyTweakAdd(first, second) - fun derivePrivateKey( parent: ExtendedPrivateKey, index: Long, @@ -73,17 +52,17 @@ class Bip32SeedDerivation( val data = arrayOf(0.toByte()).toByteArray() + parent.secretkeybytes + writeInt32BE(index.toInt()) hmac512(parent.chaincode, data) } else { - val data = pubkeyCreateBitcoin(parent.secretkeybytes) + writeInt32BE(index.toInt()) + val data = Secp256k1Instance.compressedPubKeyFor(parent.secretkeybytes) + writeInt32BE(index.toInt()) hmac512(parent.chaincode, data) } val il = i.take(32).toByteArray() val ir = i.takeLast(32).toByteArray() - require(isPrivKeyValid(il)) { "cannot generate child private key: IL is invalid" } + require(Secp256k1Instance.isPrivateKeyValid(il)) { "cannot generate child private key: IL is invalid" } - val key = sum(il, parent.secretkeybytes) + val key = Secp256k1Instance.privateKeyAdd(il, parent.secretkeybytes) - require(isPrivKeyValid(key)) { "cannot generate child private key: resulting private key is invalid" } + require(Secp256k1Instance.isPrivateKeyValid(key)) { "cannot generate child private key: resulting private key is invalid" } return ExtendedPrivateKey(key, ir) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39Mnemonics.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39Mnemonics.kt index 423a6e00ae..11fe2f7a7e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39Mnemonics.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39Mnemonics.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip06KeyDerivation import com.vitorpamplona.quartz.nip49PrivKeyEnc.PBKDF -import com.vitorpamplona.quartz.utils.sha256Hash +import com.vitorpamplona.quartz.utils.sha256.sha256 // CODE FROM: https://github.com/ACINQ/bitcoin-kmp/ @@ -79,7 +79,7 @@ object Bip39Mnemonics { val databits = bits.subList(0, bitlength) val checksumbits = bits.subList(bitlength, bits.size) val data = group(databits, 8).map { fromBinary(it) }.map { it.toByte() }.toByteArray() - val check = toBinary(sha256Hash(data)).take(data.size / 4) + val check = toBinary(sha256(data)).take(data.size / 4) require(check == checksumbits) { "invalid checksum" } } @@ -106,7 +106,7 @@ object Bip39Mnemonics { wordlist: Array, ): List { require(wordlist.size == 2048) { "invalid word list (size should be 2048)" } - val digits = toBinary(entropy) + toBinary(sha256Hash(entropy)).take(entropy.size / 4) + val digits = toBinary(entropy) + toBinary(sha256(entropy)).take(entropy.size / 4) return group(digits, 11).map(Bip39Mnemonics::fromBinary).map { wordlist[it] } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06.kt index f1b4059011..6e3fe0d6ed 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06.kt @@ -20,12 +20,8 @@ */ package com.vitorpamplona.quartz.nip06KeyDerivation -import fr.acinq.secp256k1.Secp256k1 - -class Nip06( - val secp256k1: Secp256k1, -) { - val derivation = Bip32SeedDerivation(secp256k1) +class Nip06 { + val derivation = Bip32SeedDerivation() // m/44'/1237'/'/0/0 private val nip6Base: KeyPath = diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt index 2bf2853818..7e79e267ad 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt @@ -21,14 +21,25 @@ package com.vitorpamplona.quartz.nip09Deletions import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTagIds +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -39,66 +50,60 @@ class DeletionEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + fun deleteEvents() = taggedEvents() fun deleteEventIds() = taggedEventIds() fun deleteAddresses() = taggedAddresses() - fun deleteAddressTags() = tags.mapNotNull { if (it.size > 1 && it[0] == "a") it[1] else null } + fun deleteAddressIds() = tags.mapNotNull(ATag::parseAddressId) companion object { const val KIND = 5 const val ALT = "Deletion event" - fun create( + fun build( deleteEvents: List, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DeletionEvent) -> Unit, - ) { - val content = "" - val tags = mutableListOf>() + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT) - val kinds = - deleteEvents - .mapTo(HashSet()) { - "${it.kind}" - }.map { - arrayOf("k", it) - } + deleteEvents.forEach { + eTag(ETag(it.id)) + if (it is AddressableEvent) { + aTag(it.aTag()) + } + } - tags.addAll(deleteEvents.map { arrayOf("e", it.id) }) - tags.addAll(deleteEvents.mapNotNull { if (it is AddressableEvent) arrayOf("a", it.address().toTag()) else null }) - tags.addAll(kinds) - tags.add(AltTagSerializer.toTagArray(ALT)) + pTagIds(deleteEvents.mapTo(HashSet()) { it.pubKey }) + kinds(deleteEvents.mapTo(HashSet()) { it.kind }) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + initializer() } - fun createForVersionOnly( + fun buildForVersionOnly( deleteEvents: List, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DeletionEvent) -> Unit, - ) { - val content = "" - val tags = mutableListOf>() + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT) - val kinds = - deleteEvents - .mapTo(HashSet()) { - "${it.kind}" - }.map { - arrayOf("k", it) - } + deleteEvents.forEach { + pTag(PTag(it.pubKey)) + eTag(ETag(it.id)) + } - tags.addAll(deleteEvents.map { arrayOf("e", it.id) }) - tags.addAll(kinds) - tags.add(AltTagSerializer.toTagArray(ALT)) + kinds(deleteEvents.mapTo(HashSet()) { it.kind }) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt similarity index 71% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseTextNoteEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index 65a3b0bbe3..72db040c05 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -21,27 +21,32 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithEventsOrAddresses import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithPeople -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.Note -import com.vitorpamplona.quartz.nip19Bech32.parse -import com.vitorpamplona.quartz.nip19Bech32.parseAtag import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @Immutable -open class BaseTextNoteEvent( +open class BaseThreadedEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -49,46 +54,50 @@ open class BaseTextNoteEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + fun mentions() = taggedUsers() - fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } + fun markedRoot() = tags.firstNotNullOfOrNull(MarkedETag::parseRoot) - fun forkFromAddress() = - tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "fork" }?.let { - val aTagValue = it[1] - val relay = it.getOrNull(2) + fun unmarkedRoot() = tags.firstNotNullOfOrNull(MarkedETag::parseUnmarkedRoot) - ATag.parse(aTagValue, relay) - } + fun root() = markedRoot() ?: unmarkedRoot() - fun forkFromVersion() = tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "fork" }?.get(1) + fun markedReply() = tags.lastNotNullOfOrNull(MarkedETag::parseReply) - fun isForkFromAddressWithPubkey(authorHex: HexKey) = tags.any { it.size > 3 && it[0] == "a" && it[3] == "fork" && it[1].contains(authorHex) } + fun unmarkedReply() = tags.lastNotNullOfOrNull(MarkedETag::parseUnmarkedReply) - open fun markedReplyTos(): List { - val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) - val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - return listOfNotNull(newStyleReply, newStyleRoot) - } + fun reply() = markedReply() ?: unmarkedReply() - open fun unMarkedReplyTos(): List = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] } + fun threadTags() = tags.mapNotNull(MarkedETag::parseAllThreadTags) - open fun replyingTo(): HexKey? { - val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && it[0] == "e" }?.get(1) - val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) - val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + open fun markedReplyTos() = listOfNotNull(markedRoot()?.eventId, markedReply()?.eventId) - return newStyleReply ?: newStyleRoot ?: oldStylePositional - } + open fun unmarkedReplyTos(): List = tags.mapNotNull(MarkedETag::parseOnlyPositionalThreadTagsIds) + open fun replyingTo() = + markedReply()?.eventId + ?: markedRoot()?.eventId + ?: unmarkedReply()?.eventId + + /* + Not sure if this is needed open fun replyingToAddress(): ATag? { val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && it[0] == "a" }?.let { ATag.parseAtag(it[1], it[2]) } val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "reply" }?.let { ATag.parseAtag(it[1], it[2]) } val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" }?.let { ATag.parseAtag(it[1], it[2]) } return newStyleReply ?: newStyleRoot ?: oldStylePositional - } + }*/ open fun replyingToAddressOrEvent(): String? { val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && (it[0] == "e" || it[0] == "a") }?.get(1) @@ -110,8 +119,7 @@ open class BaseTextNoteEvent( val citedUsers = mutableSetOf() findIndexTagsWithPeople(content, tags, citedUsers) - - Nip19Parser.parseAll(content).forEach { parsed -> + findNostrUris(content).forEach { parsed -> when (parsed) { is NProfile -> citedUsers.add(parsed.hex) is NPub -> citedUsers.add(parsed.hex) @@ -130,8 +138,7 @@ open class BaseTextNoteEvent( val citations = mutableSetOf() findIndexTagsWithEventsOrAddresses(content, tags, citations).toMutableSet() - - Nip19Parser.parseAll(content).forEach { entity -> + findNostrUris(content).forEach { entity -> when (entity) { is NEvent -> citations.add(entity.hex) is NAddress -> citations.add(entity.aTag()) @@ -146,10 +153,10 @@ open class BaseTextNoteEvent( fun tagsWithoutCitations(): List { val certainRepliesTo = markedReplyTos() - val uncertainRepliesTo = unMarkedReplyTos() + val uncertainRepliesTo = unmarkedReplyTos() val tagAddresses = - taggedAddresses() + taggedATags() .filter { it.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || it.kind != WikiNoteEvent.KIND) // removes forks from itself. diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index 8987c7909b..1c9e743dd5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -21,24 +21,13 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.buildHashtagTags -import com.vitorpamplona.quartz.nip10Notes.content.buildUrlRefs -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip10Notes.tags.markedETags +import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -49,9 +38,7 @@ class TextNoteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun root() = tags.firstOrNull { it.size > 3 && it[3] == "root" }?.get(1) - +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 1 const val ALT = "A short note: " @@ -61,123 +48,20 @@ class TextNoteEvent( return ALT + msg.take(50) + "..." } - fun create( - msg: String, - replyTos: List? = null, - mentions: List? = null, - addresses: List? = null, - extraTags: List? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - replyingTo: String? = null, - root: String? = null, - directMentions: Set = emptySet(), - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - forkedFrom: Event? = null, - signer: NostrSigner, + fun build( + note: String, + replyingTo: EventHintBundle? = null, + forkingFrom: EventHintBundle? = null, createdAt: Long = TimeUtils.now(), - isDraft: Boolean, - onReady: (TextNoteEvent) -> Unit, - ) { - val tags = mutableListOf>() - tags.add(AltTagSerializer.toTagArray(shortedMessageForAlt(msg))) - replyTos?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "e", - root = root, - replyingTo = replyingTo, - directMentions = directMentions, - forkedFrom = forkedFrom?.id, - ), - ) - } - mentions?.forEach { - if (it in directMentions) { - tags.add(arrayOf("p", it, "", "mention")) - } else { - tags.add(arrayOf("p", it)) - } - } - replyTos?.forEach { - if (it in directMentions) { - tags.add(arrayOf("q", it)) - } - } - addresses?.forEach { - if (it.toTag() in directMentions) { - tags.add(arrayOf("q", it.toTag())) - } - } - addresses - ?.map { it.toTag() } - ?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "a", - root = root, - replyingTo = replyingTo, - directMentions = directMentions, - forkedFrom = (forkedFrom as? AddressableEvent)?.address()?.toTag(), - ), - ) - } - tags.addAll(buildHashtagTags(findHashtags(msg) + (extraTags ?: emptyList()))) - tags.addAll(buildUrlRefs(findURLs(msg))) - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, note, createdAt) { + alt(shortedMessageForAlt(note)) - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) + if (replyingTo != null || forkingFrom != null) { + markedETags(prepareETagsAsReplyTo(replyingTo, forkingFrom)) } - geohash?.let { tags.addAll(geohashMipMap(it)) } - imetas?.forEach { tags.add(Nip92MediaAttachments.createTag(it)) } - emojis?.forEach { tags.add(it.toTagArray()) } - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), msg, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) - } + initializer() } } } - -/** - * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the - * deprecated method of positional tags to maximize backwards compatibility with clients that - * support replies but have not been updated to understand tag markers. - * - * https://github.com/nostr-protocol/nips/blob/master/10.md - * - * The tag to the root of the reply chain goes first. The tag to the reply event being responded - * to goes last. The order for any other tag does not matter, so keep the relative order. - */ -fun List.positionalMarkedTags( - tagName: String, - root: String?, - replyingTo: String?, - directMentions: Set, - forkedFrom: String?, -) = sortedWith { o1, o2 -> - when { - o1 == o2 -> 0 - o1 == root -> -1 // root goes first - o2 == root -> 1 // root goes first - o1 == replyingTo -> 1 // reply event being responded to goes last - o2 == replyingTo -> -1 // reply event being responded to goes last - else -> 0 // keep the relative order for any other tag - } -}.map { - when (it) { - root -> arrayOf(tagName, it, "", "root") - replyingTo -> arrayOf(tagName, it, "", "reply") - forkedFrom -> arrayOf(tagName, it, "", "fork") - in directMentions -> arrayOf(tagName, it, "", "mention") - else -> arrayOf(tagName, it) - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt index 8c3f1fbb7c..0af01dee06 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt @@ -23,6 +23,10 @@ package com.vitorpamplona.quartz.nip10Notes.content import com.vitorpamplona.quartz.nip01Core.core.TagArray import java.util.regex.Pattern +/** + * this is the old way of linking an e tag on the content by [index] the tag + */ + val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]") /** diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt new file mode 100644 index 0000000000..384561087b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes.content + +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser + +fun findNostrUris(content: String) = Nip19Parser.parseAll(content) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt new file mode 100644 index 0000000000..dc150479c3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt @@ -0,0 +1,258 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +data class MarkedETag( + override val eventId: HexKey, +) : GenericETag { + override var relay: String? = null + var marker: String? = null + override var author: HexKey? = null + + constructor(eventId: HexKey, relayHint: String? = null, marker: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.marker = marker + this.author = authorPubKeyHex + } + + constructor(eventId: HexKey, relayHint: String? = null, marker: MARKER? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.marker = marker?.code + this.author = authorPubKeyHex + } + + fun countMemory(): Long = + 4 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) + eventId.bytesUsedInMemory() + + (relay?.bytesUsedInMemory() ?: 0) + + (marker?.bytesUsedInMemory() ?: 0) + + (author?.bytesUsedInMemory() ?: 0) + + fun toNEvent(): String = NEvent.create(eventId, author, null, relay) + + override fun toTagArray() = arrayOfNotNull(TAG_NAME, eventId, relay, marker, author) + + enum class MARKER( + val code: String, + ) { + ROOT("root"), + REPLY("reply"), + MENTION("mention"), + FORK("fork"), + } + + companion object { + const val TAG_NAME = "e" + const val TAG_SIZE = 4 + + const val ORDER_NAME = 0 + const val ORDER_EVT_ID = 1 + const val ORDER_RELAY = 2 + const val ORDER_MARKER = 3 + const val ORDER_PUBKEY = 4 + + fun isTagged( + tag: Array, + key: HexKey, + ) = tag.size >= 2 && tag[0] == TAG_NAME && tag[1] == key + + @JvmStatic + fun parse(tag: Array): MarkedETag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + + return MarkedETag( + tag[ORDER_EVT_ID], + tag[ORDER_RELAY], + tag[ORDER_MARKER], + tag.getOrNull( + ORDER_PUBKEY, + ), + ) + } + + @JvmStatic + fun parseId(tag: Array): HexKey? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + + return tag[ORDER_EVT_ID] + } + + @JvmStatic + fun parseAllThreadTags(tag: Array): MarkedETag? = + if (tag.size >= 2 && tag[0] == TAG_NAME) { + if (tag.size <= 3) { + // simple case ["e", "id", "relay"] + MarkedETag(tag[1], tag.getOrNull(2), null as String?, null) + } else if (tag.size == 4) { + if (tag[3].isEmpty()) { + // empty tags ["e", "id", "relay", ""] + MarkedETag(tag[1], tag[2], null as String?, null) + } else if (tag[3].length == 64) { + // updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"] + MarkedETag(tag[1], tag[2], null as String?, tag[3]) + } else if (tag[3] == MARKER.ROOT.code) { + // corrent root ["e", "id", "relay", "root"] + MarkedETag(tag[1], tag[2], tag[3]) + } else if (tag[3] == MARKER.REPLY.code) { + // correct reply ["e", "id", "relay", "reply"] + MarkedETag(tag[1], tag[2], tag[3]) + } else { + // ignore "mention" and "fork" markers + null + } + } else { + // tag.size >= 5 + if (tag[3].isEmpty()) { + // empty tags ["e", "id", "relay", "", "pubkey"] + MarkedETag(tag[1], tag[2], null as String?, tag[4]) + } else if (tag[3].length == 64) { + // updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"] + MarkedETag(tag[1], tag[2], null as String?, tag[3]) + } else if (tag[3] == MARKER.ROOT.code) { + // corrent root ["e", "id", "relay", "root"] + MarkedETag(tag[1], tag[2], tag[3], tag[4]) + } else if (tag[3] == MARKER.REPLY.code) { + // correct reply ["e", "id", "relay", "reply"] + MarkedETag(tag[1], tag[2], tag[3], tag[4]) + } else { + // ignore "mention" and "fork" markers + null + } + } + } else { + null + } + + @JvmStatic + fun parseOnlyPositionalThreadTagsIds(tag: Array): HexKey? = + if (tag.size >= 2 && tag[0] == TAG_NAME) { + if (tag.size <= 3) { + // simple case ["e", "id"] + // simple case ["e", "id", "relay"] + tag[1] + } else if (tag.size == 4) { + if (tag[3].isEmpty() || tag[3].length == 64) { + // empty tags ["e", "id", "relay", ""] + tag[1] + } else { + // ignore all markers + null + } + } else { + // tag.size >= 5 + if (tag[3].isEmpty() || tag[3].length == 64) { + // empty tags ["e", "id", "relay", "", "pubkey"] + // updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"] + tag[1] + } else { + // ignore all markers + null + } + } + } else { + null + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length != 64 || tag[2].isEmpty()) return null + return EventIdHint(tag[1], tag[2]) + } + + @JvmStatic + fun parseRoot(tag: Array): MarkedETag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[ORDER_MARKER] != MARKER.ROOT.code) return null + // ["e", id hex, relay hint, marker, pubkey] + return MarkedETag( + tag[ORDER_EVT_ID], + tag[ORDER_RELAY], + tag[ORDER_MARKER], + tag.getOrNull( + ORDER_PUBKEY, + ), + ) + } + + /** + * Old positional arguments + */ + @JvmStatic + fun parseUnmarkedRoot(tag: Array): MarkedETag? = + if (tag.size in 2..3 && tag[0] == TAG_NAME) { + MarkedETag(tag[1], tag.getOrNull(2), MARKER.ROOT) + } else { + null + } + + @JvmStatic + fun parseReply(tag: Array): MarkedETag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[ORDER_MARKER] != MARKER.REPLY.code) return null + // ["e", id hex, relay hint, marker, pubkey] + return MarkedETag( + tag[ORDER_EVT_ID], + tag[ORDER_RELAY], + tag[ORDER_MARKER], + tag.getOrNull( + ORDER_PUBKEY, + ), + ) + } + + /** + * Old positional arguments + */ + @JvmStatic + fun parseUnmarkedReply(tag: Array): MarkedETag? = + if (tag.size in 2..3 && tag[0] == TAG_NAME) { + MarkedETag(tag[1], tag.getOrNull(2), MARKER.REPLY) + } else { + null + } + + @JvmStatic + fun parseRootId(tag: Array): HexKey? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[ORDER_MARKER] != MARKER.ROOT.code) return null + // ["e", id hex, relay hint, marker, pubkey] + return tag[ORDER_EVT_ID] + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: String?, + marker: MARKER?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay, marker?.code, author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt new file mode 100644 index 0000000000..7b0947b386 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes.tags + +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag + +/** + * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the + * deprecated method of positional tags to maximize backwards compatibility with clients that + * support replies but have not been updated to understand tag markers. + * + * https://github.com/nostr-protocol/nips/blob/master/10.md + * + * The tag to the root of the reply chain goes first. The tag to the reply event being responded + * to goes last. The order for any other tag does not matter, so keep the relative order. + */ +fun List.positionalMarkedTags( + tagName: String, + root: String?, + replyingTo: String?, + forkedFrom: String?, +) = sortedWith { o1, o2 -> + when { + o1 == o2 -> 0 + o1 == root -> -1 // root goes first + o2 == root -> 1 // root goes first + o1 == replyingTo -> 1 // reply event being responded to goes last + o2 == replyingTo -> -1 // reply event being responded to goes last + else -> 0 // keep the relative order for any other tag + } +}.map { + when (it) { + root -> arrayOf(tagName, it, "", "root") + replyingTo -> arrayOf(tagName, it, "", "reply") + forkedFrom -> arrayOf(tagName, it, "", "fork") + else -> arrayOf(tagName, it) + } +} + +fun List.positionalMarkedTags( + root: ETag?, + replyingTo: ETag?, + forkedFrom: ETag?, +) = sortedWith { o1, o2 -> + when { + o1.eventId == o2.eventId -> 0 + o1.eventId == root?.eventId -> -1 // root goes first + o2.eventId == root?.eventId -> 1 // root goes first + o1.eventId == replyingTo?.eventId -> 1 // reply event being responded to goes last + o2.eventId == replyingTo?.eventId -> -1 // reply event being responded to goes last + else -> 0 // keep the relative order for any other tag + } +}.map { + when (it.eventId) { + root?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.ROOT, it.author) + replyingTo?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.REPLY, it.author) + forkedFrom?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.FORK, it.author) + else -> it + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt new file mode 100644 index 0000000000..62da19c06f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes.tags + +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + +fun prepareETagsAsReplyTo( + replyingTo: EventHintBundle? = null, + forkingFrom: EventHintBundle? = null, +): List { + if (replyingTo == null && forkingFrom == null) return emptyList() + + val forkTag = forkingFrom?.toMarkedETag(MarkedETag.MARKER.FORK) + + if (replyingTo == null) { + return listOfNotNull(forkTag) + } + + val rootTag = replyingTo.event.markedRoot() ?: replyingTo.event.unmarkedRoot() + + if (rootTag == null) { + return listOfNotNull(replyingTo.toMarkedETag(MarkedETag.MARKER.ROOT), forkTag) + } + + val replyTag = replyingTo.event.markedReply() ?: replyingTo.event.unmarkedReply() + + if (replyTag == null || replyTag.eventId == rootTag.eventId) { + return listOfNotNull( + rootTag, + forkTag, + replyingTo.toMarkedETag(MarkedETag.MARKER.REPLY), + ) + } + + val branchTags = + replyingTo.event.threadTags().filter { it.eventId != rootTag.eventId }.map { + MarkedETag(it.eventId, it.relay, "", it.author) + } + + val branch = mutableListOf() + branch.add(rootTag) + branch.addAll(branchTags) + forkTag?.let { branch.add(forkTag) } + branch.add(replyingTo.toMarkedETag(MarkedETag.MARKER.REPLY)) + return branch +} + +fun prepareMarkedETagsAsReplyTo(replyingTo: EventHintBundle): List { + val rootTag = replyingTo.event.markedRoot() ?: replyingTo.event.unmarkedRoot() + + if (rootTag == null) { + return listOfNotNull(replyingTo.toMarkedETag(MarkedETag.MARKER.ROOT)) + } + + val replyTag = replyingTo.event.markedReply() ?: replyingTo.event.unmarkedReply() + + if (replyTag == null || replyTag.eventId == rootTag.eventId) { + return listOfNotNull( + rootTag, + replyingTo.toMarkedETag(MarkedETag.MARKER.REPLY), + ) + } + + val branchTags = + replyingTo.event.threadTags().filter { it.eventId != rootTag.eventId }.map { + MarkedETag(it.eventId, it.relay, "", it.author) + } + + val branch = mutableListOf() + branch.add(rootTag) + branch.addAll(branchTags) + branch.add(replyingTo.toMarkedETag(MarkedETag.MARKER.REPLY)) + return branch +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..75d0088f27 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/TagArrayBuilderExt.kt @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + +fun TagArrayBuilder.markedETag(tag: MarkedETag) = add(tag.toTagArray()) + +fun TagArrayBuilder.markedETags(tag: List) = addAll(tag.map { it.toTagArray() }) + +fun TagArrayBuilder.notify(tag: PTag) = add(tag.toTagArray()) + +fun TagArrayBuilder.notify(tag: List) = addAll(tag.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/EventExt.kt index a38053a73d..1a7b858a36 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/EventExt.kt @@ -21,8 +21,13 @@ package com.vitorpamplona.quartz.nip13Pow import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator -fun Event.pow() = PoWRankProcessor.compute(id, tags.commitedPoW()) +fun Event.pow() = PoWRankEvaluator.compute(id, tags.commitedPoW()) + +fun Event.powNonce() = tags.powTag() + +fun Event.powNonces() = tags.powTags() fun Event.hasPoWTag() = tags.hasPoW() @@ -34,7 +39,7 @@ fun Event.hasPoWTag() = tags.hasPoW() fun Event.strongPoWOrNull(min: Int = 20): Int? { val commitment = tags.commitedPoW() if (commitment != null) { - val pow = PoWRankProcessor.compute(id, commitment) + val pow = PoWRankEvaluator.compute(id, commitment) if (pow >= min) { return pow } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayBuilderExt.kt index 7213dd466a..16b41bc660 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayBuilderExt.kt @@ -20,16 +20,13 @@ */ package com.vitorpamplona.quartz.nip13Pow +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag -fun TagArrayBuilder.pow( +fun TagArrayBuilder.pow( nonce: String, commitment: Int, -) = add(PoWTag.assemble(nonce, commitment.toString())) - -fun TagArrayBuilder.pow( - nonce: String, - commitment: String, ) = add(PoWTag.assemble(nonce, commitment)) -fun TagArrayBuilder.pow(tag: PoWTag) = add(tag.toTagArray()) +fun TagArrayBuilder.pow(tag: PoWTag) = add(tag.toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayExt.kt index a89aa29987..e8e39f507f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayExt.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.quartz.nip13Pow import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent -import com.vitorpamplona.quartz.nip01Core.core.mapTagged +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag -fun TagArray.commitedPoW() = this.firstOrNull { it.size > 2 && it[0] == "nonce" }?.get(2)?.toIntOrNull() +fun TagArray.commitedPoW() = this.firstNotNullOfOrNull(PoWTag::parseCommitment) -fun TagArray.hasPoW() = this.hasTagWithContent(PoWTag.TAG_NAME) +fun TagArray.hasPoW() = this.any(PoWTag::hasTagWithContent) -fun TagArray.powTags() = this.mapTagged(PoWTag.TAG_NAME) { PoWTag.parse(it) } +fun TagArray.powTag() = this.firstNotNullOfOrNull(PoWTag::parse) + +fun TagArray.powTags() = this.mapNotNull(PoWTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/ByteArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/ByteArrayExt.kt new file mode 100644 index 0000000000..f931ecdebf --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/ByteArrayExt.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip13Pow.miner + +/** finds the sequence inside the bytearray */ +fun ByteArray.indexOf(sequence: ByteArray): Int { + if (sequence.isEmpty()) throw IllegalArgumentException("non-empty byte sequence is required") + + var matchOffset = 0 + var start = 0 + + for (offset in 0 until size) { + if (this[offset] == sequence[matchOffset]) { + if (matchOffset++ == 0) start = offset + if (matchOffset == sequence.size) return start + } else { + matchOffset = 0 + } + } + return -1 +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/MiningBuffer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/MiningBuffer.kt new file mode 100644 index 0000000000..ac5135e57f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/MiningBuffer.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip13Pow.miner + +class MiningBuffer( + val bytes: ByteArray, + val nonceStarts: Int, + val nonceEnds: Int, +) { + fun nonce() = String(bytes.copyOfRange(nonceStarts, nonceEnds)) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt new file mode 100644 index 0000000000..bd3984e9e3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip13Pow.miner + +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 +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag +import com.vitorpamplona.quartz.utils.sha256.Sha256Hasher + +class PoWMiner( + val buffer: MiningBuffer, + val desiredPoW: Int, +) { + val hasher = Sha256Hasher() + val emptyBytesForDesiredPoW = desiredPoW / 8 + + fun reachedDesiredPoW(byteArray: ByteArray) = PoWRankEvaluator.atLeastPowRank(hasher.hash(byteArray), desiredPoW, emptyBytesForDesiredPoW) + + fun run() = runDigit(buffer.nonceStarts) + + private fun runDigit(index: Int): Boolean { + for (testByte in VALID_BYTES) { + // replaces the background base by the nonce integers + buffer.bytes[index] = testByte + + if (index + 1 < buffer.nonceEnds) { + if (runDigit(index + 1)) return true + } else { + if (reachedDesiredPoW(buffer.bytes)) return true + } + } + return false + } + + companion object { + private const val STARTING_NONCE_SIZE = 5 + + // make sure these chars are not escaped by the JSON stringifier + private val VALID_CHARS: List = + ('0'..'9') + ('a'..'z') + ('A'..'Z') + "-()[]{}$@!*=;:?,".toCharArray().toList() + + private val VALID_BYTES = VALID_CHARS.map { it.code.toByte() } + + private fun randomBase(size: Int): String = CharArray(size) { VALID_CHARS.random() }.concatToString() + + /** + * The miner creates a stringified json template and changes the nonce directly in the UTF-8 ByteArray representation + * to avoid having to recompute the json objects and stringify it. + */ + fun run( + template: EventTemplate, + pubKey: HexKey, + desiredPoW: Int, + ): EventTemplate { + var nextSize = STARTING_NONCE_SIZE + + do { + val initialNonce = randomBase(nextSize) + + val bytes = + EventHasher + .makeJsonForId( + pubKey, + template.createdAt, + template.kind, + template.tags + PoWTag.assemble(initialNonce, desiredPoW), + template.content, + ).toByteArray() + + val startIndex = bytes.indexOf(initialNonce.toByteArray()) + + val buffer = MiningBuffer(bytes, startIndex, startIndex + nextSize) + + if (PoWMiner(buffer, desiredPoW).run()) { + return EventTemplate( + template.createdAt, + template.kind, + template.tags + PoWTag.assemble(buffer.nonce(), desiredPoW), + template.content, + ) + } else { + nextSize += STARTING_NONCE_SIZE + } + } while (nextSize < 50) + + throw RuntimeException("Could not find PoW") + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/PoWRankProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt similarity index 55% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/PoWRankProcessor.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt index f47e1c0b4d..5b521b180c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/PoWRankProcessor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt @@ -18,12 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip13Pow +package com.vitorpamplona.quartz.nip13Pow.miner -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey -class PoWRankProcessor { +class PoWRankEvaluator { companion object { + const val R8 = 0b00000000.toByte() + const val R7 = 0b00000001.toByte() + const val R6 = 0b00000010.toByte() + const val R5 = 0b00000100.toByte() + const val R4 = 0b00001000.toByte() + const val R3 = 0b00010000.toByte() + const val R2 = 0b00100000.toByte() + const val R1 = 0b01000000.toByte() + const val NEGATIVE = 0b10000000.toByte() + @JvmStatic fun compute( id: HexKey, @@ -63,5 +73,48 @@ class PoWRankProcessor { } return rank } + + @JvmStatic + fun calculatePowRankOf(id: ByteArray): Int { + var rank = 0 + for (byte in id) { + if (byte == R8) { + rank += 8 + } else if (byte < 0) { + break + } else { + if (byte < R6) { + rank += 7 + } else if (byte < R5) { + rank += 6 + } else if (byte < R4) { + rank += 5 + } else if (byte < R3) { + rank += 4 + } else if (byte < R2) { + rank += 3 + } else if (byte < R1) { + rank += 2 + } else { + rank += 1 + } + break + } + } + return rank + } + + @JvmStatic + fun atLeastPowRank( + id: ByteArray, + minPoW: Int, + emptyBytes: Int, + ): Boolean { + for (index in 0 until emptyBytes) { + if (id[index] != R8) return false + } + + return calculatePowRankOf(id) >= minPoW + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/PoWTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt similarity index 69% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/PoWTag.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt index c56efad21e..8c2fd55107 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/PoWTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.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.nip13Pow +package com.vitorpamplona.quartz.nip13Pow.tags import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes class PoWTag( val nonce: String, - val commitment: String?, + val commitment: Int?, ) { fun countMemory(): Long = 2 * pointerSizeInBytes + nonce.bytesUsedInMemory() + (commitment?.bytesUsedInMemory() ?: 0) @@ -34,17 +34,27 @@ class PoWTag( companion object { const val TAG_NAME = "nonce" + const val TAG_SIZE = 2 @JvmStatic - fun parse(tags: Array): PoWTag { - require(tags[0] == TAG_NAME) - return PoWTag(tags[1], tags.getOrNull(2)) + fun hasTagWithContent(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): PoWTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return PoWTag(tag[1], tag.getOrNull(2)?.toIntOrNull()) + } + + @JvmStatic + fun parseCommitment(tag: Array): Int? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag.getOrNull(2)?.toIntOrNull() } @JvmStatic fun assemble( nonce: String, - commitment: String?, - ) = arrayOfNotNull(TAG_NAME, nonce, commitment) + commitment: Int?, + ) = arrayOfNotNull(TAG_NAME, nonce, commitment.toString()) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/SubjectTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/SubjectTag.kt index 5baf21f0a6..8489b16693 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/SubjectTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/SubjectTag.kt @@ -20,24 +20,18 @@ */ package com.vitorpamplona.quartz.nip14Subject -import com.vitorpamplona.quartz.nip01Core.tags.dTags.DTag -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes - -class SubjectTag( - val subject: String, -) { - fun countMemory(): Long = 1 * pointerSizeInBytes + subject.bytesUsedInMemory() - - fun toTagArray() = assemble(subject) - +class SubjectTag { companion object { const val TAG_NAME = "subject" + const val TAG_SIZE = 2 @JvmStatic - fun parse(tags: Array): DTag { - require(tags[0] == TAG_NAME) - return DTag(tags[1]) + fun hasTagWithContent(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] } @JvmStatic diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayBuilderExt.kt index bae13bc22e..816eebc084 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayBuilderExt.kt @@ -21,5 +21,6 @@ package com.vitorpamplona.quartz.nip14Subject import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -fun TagArrayBuilder.subject(subject: String) = add(SubjectTag.assemble(subject)) +fun TagArrayBuilder.subject(subject: String) = addUnique(SubjectTag.assemble(subject)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayExt.kt index 4e7f538c8f..0300d80fe6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayExt.kt @@ -21,12 +21,9 @@ package com.vitorpamplona.quartz.nip14Subject import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent -import com.vitorpamplona.quartz.nip01Core.core.mapValues -fun TagArray.subject() = this.firstTagValue(SubjectTag.TAG_NAME) +fun TagArray.subject() = this.firstNotNullOfOrNull(SubjectTag::parse) -fun TagArray.subjects() = this.mapValues(SubjectTag.TAG_NAME) +fun TagArray.subjects() = this.mapNotNull(SubjectTag::parse) -fun TagArray.hasSubject() = this.hasTagWithContent(SubjectTag.TAG_NAME) +fun TagArray.hasSubject() = this.any(SubjectTag::hasTagWithContent) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatMessageEncryptedFileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatMessageEncryptedFileHeaderEvent.kt deleted file mode 100644 index 003a403b3e..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatMessageEncryptedFileHeaderEvent.kt +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip17Dm - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.toHexKey -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.collections.immutable.toImmutableSet - -@Immutable -class ChatMessageEncryptedFileHeaderEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - ChatroomKeyable, - NIP17Group { - /** Recipients intended to receive this conversation */ - fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null } - - override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() - - fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - - fun talkingWith(oneSideHex: String): Set { - val listedPubKeys = recipientsPubKey() - - val result = - if (pubKey == oneSideHex) { - listedPubKeys.toSet().minus(oneSideHex) - } else { - listedPubKeys.plus(pubKey).toSet().minus(oneSideHex) - } - - if (result.isEmpty()) { - // talking to myself - return setOf(pubKey) - } - - return result - } - - override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet()) - - fun url() = content - - fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) - - fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - - fun algo() = tags.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_ALGORITHM }?.get(1) - - fun key() = - tags - .firstOrNull { it.size > 1 && it[0] == ENCRYPTION_KEY } - ?.get(1) - ?.runCatching { this.hexToByteArray() } - ?.getOrNull() - - fun nonce() = - tags - .firstOrNull { it.size > 1 && it[0] == ENCRYPTION_NONCE } - ?.get(1) - ?.runCatching { this.hexToByteArray() } - ?.getOrNull() - - fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) - - fun originalHash() = tags.firstOrNull { it.size > 1 && it[0] == ORIGINAL_HASH }?.get(1) - - fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) - - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } - - fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) - - companion object { - const val KIND = 15 - const val ALT_DESCRIPTION = "Encrypted file in chat" - - const val MIME_TYPE = "file-type" - - const val ENCRYPTION_ALGORITHM = "encryption-algorithm" - const val ENCRYPTION_KEY = "decryption-key" - const val ENCRYPTION_NONCE = "decryption-nonce" - - const val FILE_SIZE = "size" - const val DIMENSION = "dim" - const val BLUR_HASH = "blurhash" - const val HASH = "x" - const val ORIGINAL_HASH = "ox" - - const val ALT = "alt" - - fun buildTags( - to: List, - repliesTo: List? = null, - contentType: String?, - algo: String, - key: ByteArray, - nonce: ByteArray? = null, - originalHash: String? = null, - hash: String? = null, - size: Int? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - sensitiveContent: Boolean? = null, - alt: String?, - ): Array> { - val repliesHex = repliesTo?.map { arrayOf("e", it) } ?: emptyList() - - return ( - to.map { arrayOf("p", it) } + repliesHex + - listOfNotNull( - contentType?.let { arrayOf(MIME_TYPE, it) }, - arrayOf(ENCRYPTION_ALGORITHM, algo), - arrayOf(ENCRYPTION_KEY, key.toHexKey()), - nonce?.let { arrayOf(ENCRYPTION_NONCE, it.toHexKey()) }, - alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf(ALT, ALT_DESCRIPTION), - originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, - hash?.let { arrayOf(HASH, it) }, - size?.let { arrayOf(FILE_SIZE, it.toString()) }, - dimensions?.let { arrayOf(DIMENSION, it.toString()) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - sensitiveContent?.let { - if (it) { - ContentWarningSerializer.toTagArray() - } else { - null - } - }, - ) - ).toTypedArray() - } - - fun create( - url: String, - to: List, - repliesTo: List? = null, - contentType: String?, - algo: String, - key: ByteArray, - nonce: ByteArray? = null, - originalHash: String? = null, - hash: String? = null, - size: Int? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - sensitiveContent: Boolean? = null, - alt: String?, - signer: NostrSigner, - isDraft: Boolean, - createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageEncryptedFileHeaderEvent) -> Unit, - ) { - val tags = buildTags(to, repliesTo, contentType, algo, key, nonce, originalHash, hash, size, dimensions, blurhash, sensitiveContent, alt) - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags, url, onReady) - } else { - signer.sign(createdAt, KIND, tags, url, onReady) - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatMessageEvent.kt deleted file mode 100644 index dceb8aede3..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatMessageEvent.kt +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip17Dm - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip14Subject.subject -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.collections.immutable.toImmutableSet - -@Immutable -class ChatMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - ChatroomKeyable, - NIP17Group { - /** Recipients intended to receive this conversation */ - fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null } - - fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - - fun talkingWith(oneSideHex: String): Set { - val listedPubKeys = recipientsPubKey() - - val result = - if (pubKey == oneSideHex) { - listedPubKeys.toSet().minus(oneSideHex) - } else { - listedPubKeys.plus(pubKey).toSet().minus(oneSideHex) - } - - if (result.isEmpty()) { - // talking to myself - return setOf(pubKey) - } - - return result - } - - override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() - - override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet()) - - companion object { - const val KIND = 14 - const val ALT = "Direct message" - - fun create( - msg: String, - to: List? = null, - subject: String? = null, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - geohash: String? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - imetas: List? = null, - emojis: List? = null, - isDraft: Boolean, - onReady: (ChatMessageEvent) -> Unit, - ) { - val tags = TagArrayBuilder() - to?.forEach { tags.add(arrayOf("p", it)) } - replyTos?.forEach { tags.add(arrayOf("e", it, "", "reply")) } - mentions?.forEach { tags.add(arrayOf("p", it, "", "mention")) } - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) - } - geohash?.let { tags.addAll(geohashMipMap(it)) } - subject?.let { tags.subject(subject) } - imetas?.forEach { - tags.add(Nip92MediaAttachments.createTag(it)) - } - emojis?.forEach { tags.add(it.toTagArray()) } - // tags.add(AltTagSerializer.toTagArray(ALT)) - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.build(), msg, onReady) - } else { - signer.sign(createdAt, KIND, tags.build(), msg, onReady) - } - } - } -} 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 a85a05c759..0ad78c9ea6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -20,16 +20,17 @@ */ package com.vitorpamplona.quartz.nip17Dm -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip59Giftwrap.GiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.SealedRumorEvent -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent class NIP17Factory { data class Result( @@ -76,131 +77,51 @@ class NIP17Factory { recursiveGiftWrapCreation(event, to.toList(), signer, wraps, onReady) } - fun createMsgNIP17( - msg: String, - to: List, + fun createMessageNIP17( + template: EventTemplate, signer: NostrSigner, - subject: String? = null, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - draftTag: String? = null, onReady: (Result) -> Unit, ) { - val senderPublicKey = signer.pubKey - - ChatMessageEvent.create( - msg = msg, - to = to, - signer = signer, - subject = subject, - replyTos = replyTos, - mentions = mentions, - zapReceiver = zapReceiver, - markAsSensitive = markAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - isDraft = draftTag != null, - imetas = imetas, - emojis = emojis, - ) { senderMessage -> - if (draftTag != null) { + signer.sign(template) { senderMessage -> + createWraps(senderMessage, senderMessage.groupMembers(), signer) { wraps -> onReady( Result( msg = senderMessage, - wraps = listOf(), + wraps = wraps, ), ) - } else { - createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps, - ), - ) - } } } } fun createEncryptedFileNIP17( - url: String, - to: List, - repliesToHex: List? = null, - contentType: String?, - algo: String, - key: ByteArray, - nonce: ByteArray? = null, - originalHash: String? = null, - hash: String? = null, - size: Int? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - sensitiveContent: Boolean? = null, - alt: String?, - draftTag: String? = null, + template: EventTemplate, signer: NostrSigner, onReady: (Result) -> Unit, ) { - val senderPublicKey = signer.pubKey - - ChatMessageEncryptedFileHeaderEvent.create( - url = url, - to = to, - repliesTo = repliesToHex, - contentType = contentType, - algo = algo, - key = key, - nonce = nonce, - originalHash = originalHash, - hash = hash, - size = size, - dimensions = dimensions, - blurhash = blurhash, - sensitiveContent = sensitiveContent, - alt = alt, - signer = signer, - isDraft = draftTag != null, - ) { senderMessage -> - if (draftTag != null) { + signer.sign(template) { senderMessage -> + createWraps(senderMessage, senderMessage.groupMembers(), signer) { wraps -> onReady( Result( msg = senderMessage, - wraps = listOf(), + wraps = wraps, ), ) - } else { - createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps, - ), - ) - } } } } fun createReactionWithinGroup( content: String, - originalNote: Event, + originalNote: EventHintBundle, to: List, signer: NostrSigner, onReady: (Result) -> Unit, ) { val senderPublicKey = signer.pubKey - ReactionEvent.create( - content, - originalNote, - signer, + signer.sign( + ReactionEvent.build(content, originalNote), ) { senderReaction -> createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> onReady( @@ -214,18 +135,16 @@ class NIP17Factory { } fun createReactionWithinGroup( - emojiUrl: EmojiUrl, - originalNote: Event, + emojiUrl: EmojiUrlTag, + originalNote: EventHintBundle, to: List, signer: NostrSigner, onReady: (Result) -> Unit, ) { val senderPublicKey = signer.pubKey - ReactionEvent.create( - emojiUrl, - originalNote, - signer, + signer.sign( + ReactionEvent.build(emojiUrl, originalNote), ) { senderReaction -> createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> onReady( 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 new file mode 100644 index 0000000000..975328dd79 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.base + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent +import kotlinx.collections.immutable.toImmutableSet + +@Immutable +open class BaseDMGroupEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig), + ChatroomKeyable, + NIP17Group, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + /** Recipients intended to receive this conversation */ + fun recipients() = tags.mapNotNull(PTag::parse) + + /** Recipients intended to receive this conversation */ + fun recipientsPubKey() = tags.mapNotNull(PTag::parseKey) + + fun talkingWith(oneSideHex: String): Set { + val listedPubKeys = recipientsPubKey() + + val result = + if (pubKey == oneSideHex) { + listedPubKeys.toSet().minus(oneSideHex) + } else { + listedPubKeys.plus(pubKey).toSet().minus(oneSideHex) + } + + if (result.isEmpty()) { + // talking to myself + return setOf(pubKey) + } + + return result + } + + 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/ChatroomKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt similarity index 92% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKey.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt index fbd6d98d8c..7a799c81fb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKey.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.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.nip17Dm +package com.vitorpamplona.quartz.nip17Dm.base import androidx.compose.runtime.Stable -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey @Stable data class ChatroomKey( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyable.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKeyable.kt similarity index 92% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyable.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKeyable.kt index 1d5ddf6351..b82ac3684b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyable.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKeyable.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip17Dm +package com.vitorpamplona.quartz.nip17Dm.base -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey interface ChatroomKeyable { fun chatroomKey(toRemove: HexKey): ChatroomKey diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Group.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt similarity index 92% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Group.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt index e49b925eec..07dd6f3d73 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Group.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip17Dm +package com.vitorpamplona.quartz.nip17Dm.base -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey interface NIP17Group { fun groupMembers(): Set diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt new file mode 100644 index 0000000000..13ebd81fef --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.files + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionAlgo +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionKey +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionNonce +import com.vitorpamplona.quartz.nip17Dm.files.tags.FileTypeTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ChatMessageEncryptedFileHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseDMGroupEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun replyTo() = tags.mapNotNull(ETag::parseId) + + fun url() = content + + fun mimeType() = tags.firstNotNullOfOrNull(FileTypeTag::parse) + + fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) + + fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) + + fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) + + fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + + fun originalHash() = tags.firstNotNullOfOrNull(OriginalHashTag::parse) + + fun algo() = tags.firstNotNullOfOrNull(EncryptionAlgo::parse) + + fun key() = tags.firstNotNullOfOrNull(EncryptionKey::parse) + + fun nonce() = tags.firstNotNullOfOrNull(EncryptionNonce::parse) + + companion object { + const val KIND = 15 + const val ALT_DESCRIPTION = "Encrypted file in chat" + + fun build( + to: List, + url: String, + cipher: AESGCM, + replyTo: EventHintBundle? = null, + mimeType: String? = null, + hash: String? = null, + size: Int? = null, + dimension: DimensionTag? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetUri: String? = null, + torrentInfoHash: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, url, createdAt) { + alt(ALT_DESCRIPTION) + + group(to) + + encryptionAlgo(cipher.name()) + encryptionKey(cipher.keyBytes) + encryptionNonce(cipher.nonce) + + replyTo?.let { reply(replyTo) } + + hash?.let { hash(it) } + size?.let { fileSize(it) } + mimeType?.let { mimeType(it) } + dimension?.let { dimension(it) } + blurhash?.let { blurhash(it) } + originalHash?.let { originalHash(it) } + magnetUri?.let { magnet(it) } + torrentInfoHash?.let { torrentInfohash(it) } + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..4ea99d0601 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.files + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionAlgo +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionKey +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionNonce +import com.vitorpamplona.quartz.nip17Dm.files.tags.FileTypeTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash + +fun TagArrayBuilder.reply(msg: MarkedETag) = add(msg.toTagArray()) + +fun TagArrayBuilder.reply(msg: EventHintBundle) = reply(msg.toMarkedETag(MarkedETag.MARKER.REPLY)) + +fun TagArrayBuilder.group(list: List) = pTags(list) + +fun TagArrayBuilder.group(pubkey: PTag) = pTag(pubkey) + +fun TagArrayBuilder.encryptionAlgo(algo: String) = add(EncryptionAlgo.assemble(algo)) + +fun TagArrayBuilder.encryptionKey(key: ByteArray) = add(EncryptionKey.assemble(key)) + +fun TagArrayBuilder.encryptionNonce(nonce: ByteArray) = add(EncryptionNonce.assemble(nonce)) + +fun TagArrayBuilder.mimeType(mimeType: String) = add(FileTypeTag.assemble(mimeType)) + +fun TagArrayBuilder.hash(hash: HexKey) = add(HashSha256Tag.assemble(hash)) + +fun TagArrayBuilder.fileSize(size: Int) = add(SizeTag.assemble(size)) + +fun TagArrayBuilder.dimension(dim: DimensionTag) = add(DimensionTag.assemble(dim)) + +fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) + +fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) + +fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) + +fun TagArrayBuilder.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri)) + +fun TagArrayBuilder.image(imageUrl: HexKey) = add(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.thumb(trumbUrl: HexKey) = add(ThumbTag.assemble(trumbUrl)) + +fun TagArrayBuilder.summary(summary: HexKey) = add(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.fallback(fallbackUrl: HexKey) = add(FallbackTag.assemble(fallbackUrl)) + +fun TagArrayBuilder.service(service: HexKey) = add(ServiceTag.assemble(service)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/AESGCM.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt similarity index 77% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/AESGCM.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt index 84572f733b..391cda1204 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/AESGCM.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt @@ -18,17 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip17Dm +package com.vitorpamplona.quartz.nip17Dm.files.encryption -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.toHexKey +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.RandomInstance +import java.security.GeneralSecurityException import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec class AESGCM( - val keyBytes: ByteArray = CryptoUtils.random(32), - val nonce: ByteArray = CryptoUtils.random(16), + val keyBytes: ByteArray = RandomInstance.bytes(32), + val nonce: ByteArray = RandomInstance.bytes(16), ) : NostrCipher { private fun newCipher() = Cipher.getInstance("AES/GCM/NoPadding") @@ -56,6 +58,14 @@ class AESGCM( doFinal(bytesToDecrypt) } + override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + decrypt(bytesToDecrypt) + } catch (e: GeneralSecurityException) { + Log.w("AESGCM", "Failed to decrypt", e) + null + } + companion object { const val NAME = "aes-gcm" } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NostrCipher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt similarity index 91% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NostrCipher.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt index 6a90da014b..d01ff733ca 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NostrCipher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.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.nip17Dm +package com.vitorpamplona.quartz.nip17Dm.files.encryption interface NostrCipher { fun name(): String @@ -26,4 +26,6 @@ interface NostrCipher { fun encrypt(bytesToEncrypt: ByteArray): ByteArray fun decrypt(bytesToDecrypt: ByteArray): ByteArray + + fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionAlgo.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionAlgo.kt new file mode 100644 index 0000000000..a44727038b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionAlgo.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.files.tags + +class EncryptionAlgo { + companion object { + const val TAG_NAME = "encryption-algorithm" + const val TAG_SIZE = 2 + + @JvmStatic + fun isTag(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(excerpt: String) = arrayOf(TAG_NAME, excerpt) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionKey.kt new file mode 100644 index 0000000000..d3ed47c411 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionKey.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.files.tags + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +class EncryptionKey { + companion object { + const val TAG_NAME = "decryption-key" + const val TAG_SIZE = 2 + + @JvmStatic + fun isTag(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): ByteArray? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return runCatching { tag[1].hexToByteArray() }.getOrNull() + } + + @JvmStatic + fun assemble(key: ByteArray) = arrayOf(TAG_NAME, key.toHexKey()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionNonce.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionNonce.kt new file mode 100644 index 0000000000..b34eb627ef --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionNonce.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.files.tags + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +class EncryptionNonce { + companion object { + const val TAG_NAME = "decryption-nonce" + const val TAG_SIZE = 2 + + @JvmStatic + fun isTag(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): ByteArray? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return runCatching { tag[1].hexToByteArray() }.getOrNull() + } + + @JvmStatic + fun assemble(nonce: ByteArray) = arrayOf(TAG_NAME, nonce.toHexKey()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/FileTypeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/FileTypeTag.kt new file mode 100644 index 0000000000..38620fd2d5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/FileTypeTag.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.files.tags + +class FileTypeTag { + companion object { + const val TAG_NAME = "file-type" + const val TAG_SIZE = 2 + + fun isIn( + tag: Array, + mimeTypes: Set, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1] in mimeTypes + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt new file mode 100644 index 0000000000..cf9292b353 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.messages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent.Companion.ALT_DESCRIPTION +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ChatMessageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseDMGroupEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun replyTo() = tags.mapNotNull(ETag::parseId) + + companion object { + const val KIND = 14 + const val ALT = "Direct message" + + fun build( + msg: String, + to: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT_DESCRIPTION) + group(to) + initializer() + } + + fun reply( + msg: String, + reply: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT_DESCRIPTION) + reply(reply) + group((reply.event.recipients() + reply.toPTag()).distinctBy { it.pubKey }) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..142a18c1d6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.messages + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip14Subject.SubjectTag +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent + +fun TagArrayBuilder.reply(msg: MarkedETag) = add(msg.toTagArray()) + +fun TagArrayBuilder.reply(msg: EventHintBundle) = reply(msg.toMarkedETag(MarkedETag.MARKER.REPLY)) + +fun TagArrayBuilder.group(list: List) = pTags(list) + +fun TagArrayBuilder.group(pubkey: PTag) = pTag(pubkey) + +fun TagArrayBuilder.changeSubject(newSubject: String) = addUnique(SubjectTag.assemble(newSubject)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatMessageRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt similarity index 88% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatMessageRelayListEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt index 8b09934216..300c2b19d8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/ChatMessageRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt @@ -18,15 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip17Dm +package com.vitorpamplona.quartz.nip17Dm.settings import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +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.ATag -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -50,15 +51,17 @@ class ChatMessageRelayListEvent( companion object { const val KIND = 10050 + 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 = ATag.assembleATagId(KIND, pubKey, FIXED_D_TAG) + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) fun createTagArray(relays: List): Array> = relays .map { arrayOf("relay", it) - }.plusElement(AltTagSerializer.toTagArray("Relay list to receive private messages")) + }.plusElement(AltTag.assemble("Relay list to receive private messages")) .toTypedArray() fun updateRelayList( 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 124d678b0f..581e66a5d8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt @@ -21,11 +21,24 @@ package com.vitorpamplona.quartz.nip18Reposts import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -36,10 +49,29 @@ class GenericRepostEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + fun boostedEvents() = tags.mapNotNull(ETag::parse) + + fun boostedATags() = tags.mapNotNull(ATag::parse) + + fun boostedAddresses() = tags.mapNotNull(ATag::parseAddress) + + fun originalAuthors() = tags.mapNotNull(PTag::parse) + + fun boostedEventIds() = tags.mapNotNull(ETag::parseId) + + fun boostedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + fun originalAuthorKeys() = tags.mapNotNull(PTag::parseKey) fun containedPost() = try { @@ -52,6 +84,25 @@ class GenericRepostEvent( const val KIND = 16 const val ALT = "Generic repost" + fun build( + boostedPost: Event, + eventSourceRelay: String?, + authorHomeRelay: String?, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { + alt(ALT) + + kind(boostedPost.kind) + pTag(PTag(boostedPost.pubKey, authorHomeRelay)) + eTag(ETag(boostedPost.id, eventSourceRelay, boostedPost.pubKey)) + if (boostedPost is AddressableEvent) { + aTag(boostedPost.aTag(eventSourceRelay)) + } + + initializer() + } + fun create( boostedPost: Event, signer: NostrSigner, @@ -67,11 +118,11 @@ class GenericRepostEvent( ) if (boostedPost is AddressableEvent) { - tags.add(arrayOf("a", boostedPost.address().toTag())) + tags.add(arrayOf("a", boostedPost.aTag().toTag())) } tags.add(arrayOf("k", "${boostedPost.kind}")) - tags.add(AltTagSerializer.toTagArray(ALT)) + tags.add(AltTag.assemble(ALT)) signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt index 332ab407b8..691ec35c60 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt @@ -21,13 +21,22 @@ package com.vitorpamplona.quartz.nip18Reposts import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents -import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -38,10 +47,29 @@ class RepostEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun boostedPost() = taggedEvents() +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - fun originalAuthor() = taggedUsers() + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + fun boostedEvents() = tags.mapNotNull(ETag::parse) + + fun boostedATags() = tags.mapNotNull(ATag::parse) + + fun boostedAddresses() = tags.mapNotNull(ATag::parseAddress) + + fun originalAuthors() = tags.mapNotNull(PTag::parse) + + fun boostedEventIds() = tags.mapNotNull(ETag::parseId) + + fun boostedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + fun originalAuthorKeys() = tags.mapNotNull(PTag::parseKey) fun containedPost() = try { @@ -54,26 +82,23 @@ class RepostEvent( const val KIND = 6 const val ALT = "Repost event" - fun create( + fun build( boostedPost: Event, - signer: NostrSigner, + eventSourceRelay: String?, + authorHomeRelay: String?, createdAt: Long = TimeUtils.now(), - onReady: (RepostEvent) -> Unit, - ) { - val content = boostedPost.toJson() - - val replyToPost = arrayOf("e", boostedPost.id) - val replyToAuthor = arrayOf("p", boostedPost.pubKey) - - var tags: Array> = arrayOf(replyToPost, replyToAuthor) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { + alt(ALT) + pTag(PTag(boostedPost.pubKey, authorHomeRelay)) + eTag(ETag(boostedPost.id, eventSourceRelay, boostedPost.pubKey)) if (boostedPost is AddressableEvent) { - tags += listOf(arrayOf("a", boostedPost.address().toTag())) + aTag(boostedPost.aTag(eventSourceRelay)) } + kind(boostedPost.kind) - tags += listOf(AltTagSerializer.toTagArray(ALT)) - - signer.sign(createdAt, KIND, tags, content, onReady) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt new file mode 100644 index 0000000000..eb2d964f51 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip18Reposts.quotes + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.Note + +fun Note.toQuoteTag() = QEventTag(hex, null, null) + +fun NEvent.toQuoteTag() = QEventTag(hex, relay.firstOrNull(), author) + +fun NAddress.toQuoteTag() = QAddressableTag(kind, author, dTag, relay.firstOrNull()) + +fun NEmbed.toQuoteTag() = + if (event is AddressableEvent) { + QAddressableTag(event.kind, event.pubKey, event.dTag(), null) + } else { + QEventTag(event.id, null, event.pubKey) + } + +fun Note.toQuoteTagArray() = QEventTag.assemble(hex, null, null) + +fun NEvent.toQuoteTagArray() = QEventTag.assemble(hex, relay.firstOrNull(), author) + +fun NAddress.toQuoteTagArray() = QAddressableTag.assemble(kind, author, dTag, relay.firstOrNull()) + +fun NEmbed.toQuoteTagArray() = + if (event is AddressableEvent) { + QAddressableTag.assemble(event.kind, event.pubKey, event.dTag(), null) + } else { + QEventTag.assemble(event.id, null, event.pubKey) + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EventExt.kt new file mode 100644 index 0000000000..f499b58069 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EventExt.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip18Reposts.quotes + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +fun Event.forEachTaggedQuoteId(onEach: (eventId: HexKey) -> Unit) = tags.forEachTaggedQuoteId(onEach) + +fun Event.mapTaggedQuoteId(map: (eventId: HexKey) -> R) = tags.mapTaggedQuoteId(map) + +fun Event.taggedQuotes() = tags.taggedQuotes() + +fun Event.taggedQuoteIds() = tags.taggedQuoteIds() + +fun Event.firstTaggedQuote() = tags.firstTaggedQuote() + +fun Event.isTaggedQuote(idHex: String) = tags.isTaggedQuote(idHex) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt new file mode 100644 index 0000000000..a0e4ac3a8d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip18Reposts.quotes + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.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 + +@Immutable +data class QAddressableTag( + val address: Address, +) : QTag { + var relay: String? = null + + constructor( + address: Address, + relayHint: String?, + ) : this(address) { + this.relay = relayHint + } + + constructor( + kind: Int, + pubKeyHex: HexKey, + dTag: String, + relayHint: String?, + ) : this(Address(kind, pubKeyHex, dTag)) { + this.relay = relayHint + } + + fun countMemory(): Long = + 2 * pointerSizeInBytes + + address.countMemory() + + (relay?.bytesUsedInMemory() ?: 0) + + override fun toTagArray() = assemble(address, relay) + + companion object { + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): QAddressableTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == QTag.TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val address = Address.parse(tag[1]) ?: return null + return QAddressableTag(address, tag.getOrNull(2)) + } + + @JvmStatic + fun assemble( + kind: Int, + pubKeyHex: HexKey, + dTag: String, + relay: String?, + ) = arrayOfNotNull(QTag.TAG_NAME, Address.assemble(kind, pubKeyHex, dTag), relay) + + @JvmStatic + fun assemble( + address: Address, + relay: String?, + ) = arrayOfNotNull(QTag.TAG_NAME, address.toValue(), relay) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt new file mode 100644 index 0000000000..511bb12d0f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip18Reposts.quotes + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +data class QEventTag( + val eventId: HexKey, +) : QTag { + var relay: String? = null + var author: HexKey? = null + + constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.author = authorPubKeyHex + } + + fun countMemory(): Long = + 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) + eventId.bytesUsedInMemory() + + (relay?.bytesUsedInMemory() ?: 0) + + (author?.bytesUsedInMemory() ?: 0) + + override fun toTagArray() = assemble(eventId, relay, author) + + companion object { + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): QEventTag? { + if (tag.size < TAG_SIZE || tag[0] != QTag.TAG_NAME) return null + return QEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: String?, + author: HexKey?, + ) = arrayOfNotNull(QTag.TAG_NAME, eventId, relay, author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt new file mode 100644 index 0000000000..52e6d14904 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip18Reposts.quotes + +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint + +interface QTag { + fun toTagArray(): Array + + companion object { + const val TAG_NAME = "q" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): QTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return if (tag[1].length == 64) { + QEventTag.parse(tag) + } else { + QAddressableTag.parse(tag) + } + } + + @JvmStatic + fun parseEventAsHint(tag: Array): EventIdHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length != 64 || tag[2].isEmpty()) return null + return EventIdHint(tag[1], tag[2]) + } + + @JvmStatic + fun parseAddressAsHint(tag: Array): AddressHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length == 64 || !tag[1].contains(':') || tag[2].isEmpty()) return null + return AddressHint(tag[1], tag[2]) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..0017af7e01 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip18Reposts.quotes + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.people.toQuoteTagArray +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.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.Note + +fun TagArrayBuilder.quote(tag: QTag) = add(tag.toTagArray()) + +fun TagArrayBuilder.quotes(tag: List) = addAll(tag.map { it.toTagArray() }) + +fun TagArrayBuilder.quote(entity: Entity) = + when (entity) { + is Note -> add(entity.toQuoteTagArray()) + is NEvent -> add(entity.toQuoteTagArray()) + is NAddress -> add(entity.toQuoteTagArray()) + is NEmbed -> add(entity.toQuoteTagArray()) + is NPub -> add(entity.toQuoteTagArray()) + is NProfile -> add(entity.toQuoteTagArray()) + else -> { + this + } + } + +fun TagArrayBuilder.quotes(entities: List) = entities.forEach { quote(it) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt new file mode 100644 index 0000000000..3b9163da7e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip18Reposts.quotes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.forEachTagged +import com.vitorpamplona.quartz.nip01Core.core.isTagged +import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged +import com.vitorpamplona.quartz.nip01Core.core.mapValues + +fun TagArray.forEachTaggedQuoteId(onEach: (eventId: HexKey) -> Unit) = this.forEachTagged(QTag.TAG_NAME, onEach) + +fun TagArray.mapTaggedQuoteId(map: (eventId: HexKey) -> R) = this.mapValueTagged(QTag.TAG_NAME, map) + +fun TagArray.taggedQuotes() = this.mapNotNull(QTag.Companion::parse) + +fun TagArray.taggedQuoteIds() = this.mapValues(QTag.TAG_NAME) + +fun TagArray.firstTaggedQuote() = this.firstNotNullOfOrNull(QTag.Companion::parse) + +fun TagArray.isTaggedQuote(idHex: String) = this.isTagged(QTag.TAG_NAME, idHex) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt index 2ad1577843..67f471930e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.utils.Hex fun ATag.Companion.isATag(key: String): Boolean = key.startsWith("naddr1") || key.contains(":") -fun ATag.Companion.parse( +fun ATag.Companion.parseAny( address: String, relay: String?, ): ATag? = diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt index ac8525a35f..4bf2ce9284 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt @@ -25,9 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -fun Event.toNIP19(): String = +fun Event.toNIP19(relayHint: String? = null): String = if (this is AddressableEvent) { - ATag(kind, pubKey, dTag(), null).toNAddr() + ATag(kind, pubKey, dTag(), relayHint).toNAddr() } else { - NEvent.create(id, pubKey, kind, null) + NEvent.create(id, pubKey, kind, relayHint) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index 6d24c4c1a3..7d1cceff91 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.quartz.nip19Bech32 import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.Entity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -152,7 +152,7 @@ object Nip19Parser { fun decodePublicKey(key: String): ByteArray = when (val parsed = Nip19Parser.uriToRoute(key)?.entity) { - is NSec -> KeyPair(privKey = key.bechToBytes()).pubKey + is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()) is NPub -> parsed.hex.hexToByteArray() is NProfile -> parsed.hex.hexToByteArray() else -> Hex.decode(key) // crashes on purpose @@ -179,7 +179,7 @@ fun decodePrivateKeyAsHexOrNull(key: String): HexKey? = fun decodePublicKeyAsHexOrNull(key: String): HexKey? = try { when (val parsed = Nip19Parser.uriToRoute(key)?.entity) { - is NSec -> KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey() + is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey() is NPub -> parsed.hex is NProfile -> parsed.hex is Note -> null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt index dd4dd160f3..d929089106 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.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. */ -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt index 77654be97a..093121ae5a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt @@ -51,6 +51,12 @@ object Bech32 { const val ALPHABET: String = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" const val ALPHABET_UPPERCASE: String = "QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L" + private const val GEN0 = 0x3b6a57b2 + private const val GEN1 = 0x26508e6d + private const val GEN2 = 0x1ea119fa + private const val GEN3 = 0x3d4233dd + private const val GEN4 = 0x2a1462b3 + enum class Encoding( val constant: Int, ) { @@ -74,17 +80,17 @@ object Bech32 { fun expand(hrp: String): Array { val half = hrp.length + 1 val size = half + hrp.length + val firstPart = hrp.indices + val secondPart = half until size return Array(size) { when (it) { - in hrp.indices -> hrp[it].code.shr(5).toByte() - in half until size -> (hrp[it - half].code and 31).toByte() + in firstPart -> hrp[it].code.shr(5).toByte() + in secondPart -> (hrp[it - half].code and 31).toByte() else -> 0 } } } - private val GEN = arrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) - fun polymod( values: Array, values1: Array, @@ -93,16 +99,20 @@ object Bech32 { values.forEach { v -> val b = chk shr 25 chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() - for (i in 0..4) { - if (((b shr i) and 1) != 0) chk = chk xor GEN[i] - } + if (((b shr 0) and 1) != 0) chk = chk xor GEN0 + if (((b shr 1) and 1) != 0) chk = chk xor GEN1 + if (((b shr 2) and 1) != 0) chk = chk xor GEN2 + if (((b shr 3) and 1) != 0) chk = chk xor GEN3 + if (((b shr 4) and 1) != 0) chk = chk xor GEN4 } values1.forEach { v -> val b = chk shr 25 chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() - for (i in 0..4) { - if (((b shr i) and 1) != 0) chk = chk xor GEN[i] - } + if (((b shr 0) and 1) != 0) chk = chk xor GEN0 + if (((b shr 1) and 1) != 0) chk = chk xor GEN1 + if (((b shr 2) and 1) != 0) chk = chk xor GEN2 + if (((b shr 3) and 1) != 0) chk = chk xor GEN3 + if (((b shr 4) and 1) != 0) chk = chk xor GEN4 } return chk } @@ -128,8 +138,7 @@ object Bech32 { else -> addChecksum(hrp, int5s, encoding) } - val charArray = - CharArray(dataWithChecksum.size) { ALPHABET[dataWithChecksum[it].toInt()] }.concatToString() + val charArray = CharArray(dataWithChecksum.size) { ALPHABET[dataWithChecksum[it].toInt()] }.concatToString() return hrp + "1" + charArray } @@ -231,8 +240,8 @@ object Bech32 { @JvmStatic public fun eight2five(input: ByteArray): ArrayList { var buffer = 0L - val output = - ArrayList(input.size * 2) // larger array on purpose. Checksum is added later. + // larger array on purpose. Checksum is added later. + val output = ArrayList(input.size * 2) var count = 0 input.forEach { b -> buffer = (buffer shl 8) or (b.toLong() and 0xff) 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 ed507b0037..0e231211be 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 @@ -26,7 +26,7 @@ import addString import addStringIfNotNull import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv @@ -40,7 +40,7 @@ data class NAddress( val dTag: String, val relay: List, ) : Entity { - fun aTag(): String = ATag.assembleATagId(kind, author, dTag) + fun aTag(): String = Address.assemble(kind, author, dTag) companion object { fun parse(naddr: String): NAddress? { @@ -75,12 +75,14 @@ data class NAddress( kind: Int, pubKeyHex: String, dTag: String, - relay: String?, + vararg relays: String?, ): String = TlvBuilder() .apply { addString(TlvTypes.SPECIAL, dTag) - addStringIfNotNull(TlvTypes.RELAY, relay) + relays.forEach { + addStringIfNotNull(TlvTypes.RELAY, it) + } addHex(TlvTypes.AUTHOR, pubKeyHex) addInt(TlvTypes.KIND, kind) }.build() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt index bf36b99788..7d7d1ae2e1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt @@ -59,12 +59,14 @@ data class NEvent( idHex: String, author: String?, kind: Int?, - relay: String?, + vararg relays: String?, ): String = TlvBuilder() .apply { addHex(TlvTypes.SPECIAL, idHex) - addStringIfNotNull(TlvTypes.RELAY, relay) + relays.forEach { + addStringIfNotNull(TlvTypes.RELAY, it) + } addHexIfNotNull(TlvTypes.AUTHOR, author) addIntIfNotNull(TlvTypes.KIND, kind) }.build() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt index c4ceb3bf30..c31746b683 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt @@ -51,12 +51,12 @@ data class NProfile( fun create( authorPubKeyHex: String, - relay: List, + relays: List, ): String = TlvBuilder() .apply { addHex(TlvTypes.SPECIAL, authorPubKeyHex) - relay.forEach { + relays.forEach { addStringIfNotNull(TlvTypes.RELAY, it) } }.build() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt index d475c37f27..f9b8fba128 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.toHexKey +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.nip19Bech32.toNpub @Immutable diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt index 7a18c3fd67..09f81b0b00 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey @Immutable data class NSec( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt index fa533f42f9..e52bb0ddc6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.toHexKey +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.nip19Bech32.toNote @Immutable diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/Tlv.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/Tlv.kt index d99bc59f32..27a4b703c3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/Tlv.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/Tlv.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip19Bech32.tlv -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt index 08f2519d9e..1c6efb71dd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.nip19Bech32.tlv -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import java.io.ByteArrayOutputStream class TlvBuilder { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt index 5e7d3e93e1..9cb8319f02 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt @@ -21,6 +21,9 @@ package com.vitorpamplona.quartz.nip21UriScheme import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip19Bech32.toNIP19 -fun Event.toNostrUri(): String = "nostr:${toNIP19()}" +fun Event.toNostrUri(relayHint: String? = null): String = "nostr:${toNIP19(relayHint)}" + +fun EventHintBundle.toNostrUri(): String = "nostr:${toNEvent()}" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index 1f56efc181..83e94ac49a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -21,30 +21,31 @@ package com.vitorpamplona.quartz.nip22Comments import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -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.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.buildHashtagTags -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip10Notes.PTag -import com.vitorpamplona.quartz.nip10Notes.content.buildUrlRefs -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip19Bech32.parseAtag -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyAddressTag +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyAuthorTag +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyEventTag +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyIdentifierTag +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyKindTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootAddressTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootAuthorTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers +import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @Immutable class CommentEvent( @@ -54,17 +55,32 @@ class CommentEvent( tags: Array>, content: String, sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), - RootScope { - fun root() = tags.firstOrNull { it.size > 3 && it[3] == "root" }?.get(1) +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope, + EventHintProvider, + PubKeyHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(RootAuthorTag::parseAsHint) + tags.mapNotNull(ReplyAuthorTag::parseAsHint) - fun getRootScopes() = tags.filter { it.size > 1 && it[0] == "I" || it[0] == "A" || it[0] == "E" } + override fun eventHints() = tags.mapNotNull(RootEventTag::parseAsHint) + tags.mapNotNull(ReplyEventTag::parseAsHint) - fun getRootKinds() = tags.filter { it.size > 1 && it[0] == "K" } + override fun addressHints() = tags.mapNotNull(RootAddressTag::parseAsHint) + tags.mapNotNull(ReplyAddressTag::parseAsHint) - fun getDirectReplies() = tags.filter { it.size > 1 && it[0] == "i" || it[0] == "a" || it[0] == "e" } + fun rootAuthor() = tags.firstNotNullOfOrNull(RootAuthorTag::parse) - fun getDirectKinds() = tags.filter { it.size > 1 && it[0] == "k" } + fun replyAuthor() = tags.firstNotNullOfOrNull(ReplyAuthorTag::parse) + + fun rootAuthors() = tags.filter(RootAuthorTag::match) + + fun replyAuthors() = tags.filter(ReplyAuthorTag::match) + + fun rootScopes() = tags.filter { RootIdentifierTag.match(it) || RootAddressTag.match(it) || RootEventTag.match(it) } + + fun rootKinds() = tags.filter(RootKindTag::match) + + fun directReplies() = tags.filter { ReplyIdentifierTag.match(it) || ReplyAddressTag.match(it) || ReplyEventTag.match(it) } + + fun directKinds() = tags.filter(ReplyKindTag::match) fun isGeohashTag(tag: Array) = tag.size > 1 && (tag[0] == "i" || tag[0] == "I") && tag[1].startsWith("geo:") @@ -80,157 +96,71 @@ class CommentEvent( fun isTaggedGeoHashes(hashtags: Set) = geohashes().any { it in hashtags } - override fun markedReplyTos(): List = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + tags.filter { it.size > 1 && it[0] == "E" }.map { it[1] } + override fun markedReplyTos(): List = + tags.mapNotNull(ReplyEventTag::parseKey) + + tags.mapNotNull(RootEventTag::parseKey) - override fun unMarkedReplyTos() = emptyList() + override fun unmarkedReplyTos() = emptyList() override fun replyingTo(): HexKey? = - tags.lastOrNull { it.size > 1 && it[0] == "e" }?.get(1) - ?: tags.lastOrNull { it.size > 1 && it[0] == "E" }?.get(1) + tags.lastNotNullOfOrNull(ReplyEventTag::parseKey) + ?: tags.lastNotNullOfOrNull(RootEventTag::parseKey) - override fun replyingToAddress(): ATag? = - tags.lastOrNull { it.size > 1 && it[0] == "a" }?.let { ATag.parseAtag(it[1], it.getOrNull(2)) } - ?: tags.lastOrNull { it.size > 1 && it[0] == "A" }?.let { ATag.parseAtag(it[1], it.getOrNull(2)) } + fun replyingToAddressId(): String? = + tags.lastNotNullOfOrNull(RootAddressTag::parseAddress) + ?: tags.lastNotNullOfOrNull(ReplyAddressTag::parseAddress) - override fun replyingToAddressOrEvent(): HexKey? = replyingToAddress()?.toTag() ?: replyingTo() + override fun replyingToAddressOrEvent(): HexKey? = replyingToAddressId() ?: replyingTo() companion object { const val KIND = 1111 + const val ALT = "Reply to " - fun rootGeohashMipMap(geohash: String): Array> = - geohash.indices - .asSequence() - .map { arrayOf("I", "geo:" + geohash.substring(0, it + 1)) } - .toList() - .reversed() - .toTypedArray() - - fun firstReplyToEvent( + fun replyBuilder( msg: String, replyingTo: EventHintBundle, - usersMentioned: Set = emptySet(), - addressesMentioned: Set = emptySet(), - eventsMentioned: Set = emptySet(), - imetas: List? = null, - emojis: List? = null, - geohash: String? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - isDraft: Boolean, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CommentEvent) -> Unit, - ) { - val tags = mutableListOf>() + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT + replyingTo.toNostrUri()) - if (replyingTo.event is AddressableEvent) { - tags.add(removeTrailingNullsAndEmptyOthers("A", replyingTo.event.addressTag(), replyingTo.relay)) - tags.add(removeTrailingNullsAndEmptyOthers("a", replyingTo.event.addressTag(), replyingTo.relay)) - } - - tags.add(removeTrailingNullsAndEmptyOthers("E", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey)) - tags.add(arrayOf("K", "${replyingTo.event.kind}")) - - tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey)) - tags.add(arrayOf("k", "${replyingTo.event.kind}")) - - create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, emojis, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady) - } - - fun replyComment( - msg: String, - replyingTo: EventHintBundle, - usersMentioned: Set = emptySet(), - addressesMentioned: Set = emptySet(), - eventsMentioned: Set = emptySet(), - imetas: List? = null, - emojis: List? = null, - geohash: String? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - isDraft: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommentEvent) -> Unit, - ) { - val tags = mutableListOf>() - - tags.addAll(replyingTo.event.getRootScopes()) - tags.addAll(replyingTo.event.getRootKinds()) - - tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey)) - tags.add(arrayOf("k", "${replyingTo.event.kind}")) - - create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, emojis, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady) - } - - fun createGeoComment( - msg: String, - geohash: String? = null, - usersMentioned: Set = emptySet(), - addressesMentioned: Set = emptySet(), - eventsMentioned: Set = emptySet(), - imetas: List? = null, - emojis: List? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - isDraft: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommentEvent) -> Unit, - ) { - val tags = mutableListOf>() - geohash?.let { tags.addAll(rootGeohashMipMap(it)) } - tags.add(arrayOf("K", "geo")) - - create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, emojis, null, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady) - } - - private fun create( - msg: String, - tags: MutableList>, - usersMentioned: Set = emptySet(), - addressesMentioned: Set = emptySet(), - eventsMentioned: Set = emptySet(), - imetas: List? = null, - emojis: List? = null, - geohash: String? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - isDraft: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommentEvent) -> Unit, - ) { - usersMentioned.forEach { tags.add(it.toPTagArray()) } - addressesMentioned.forEach { tags.add(it.toQTagArray()) } - eventsMentioned.forEach { tags.add(it.toQTagArray()) } - - tags.addAll(buildHashtagTags(findHashtags(msg))) - tags.addAll(buildUrlRefs(findURLs(msg))) - - emojis?.forEach { tags.add(it.toTagArray()) } - - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) - } - geohash?.let { tags.addAll(geohashMipMap(it)) } - imetas?.forEach { - tags.add(Nip92MediaAttachments.createTag(it)) - } - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), msg, onReady) + if (replyingTo.event is CommentEvent) { + addAll(replyingTo.event.rootScopes()) + addAll(replyingTo.event.rootKinds()) + addAll(replyingTo.event.rootAuthors()) } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + if (replyingTo.event is AddressableEvent) { + rootAddress(replyingTo.event.addressTag(), replyingTo.relay) + replyAddress(replyingTo.event.addressTag(), replyingTo.relay) + } + + rootEvent(replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey) + rootKind(replyingTo.event.kind) + rootAuthor(replyingTo.event.pubKey, replyingTo.authorHomeRelay) } + + replyEvent(replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey) + replyKind(replyingTo.event.kind) + replyAuthor(replyingTo.event.pubKey, replyingTo.authorHomeRelay) + + initializer() + } + + fun replyExternalIdentity( + msg: String, + extId: ExternalId, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT + extId.toScope()) + + rootExternalIdentity(extId) + rootKind(extId) + + replyExternalIdentity(extId) + replyKind(extId) + + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..3592427787 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyAddressTag +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyAuthorTag +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyEventTag +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyIdentifierTag +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyKindTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootAddressTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootAuthorTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId + +fun TagArrayBuilder.rootAddress( + addressId: String, + relayHint: String?, +) = addUnique(RootAddressTag.assemble(addressId, relayHint)) + +fun TagArrayBuilder.rootEvent( + eventId: String, + relayHint: String?, + pubkey: String?, +) = addUnique(RootEventTag.assemble(eventId, relayHint, pubkey)) + +fun TagArrayBuilder.rootExternalIdentity(id: ExternalId) = addAll(RootIdentifierTag.assemble(id)) + +fun TagArrayBuilder.rootKind(kind: String) = addUnique(RootKindTag.assemble(kind)) + +fun TagArrayBuilder.rootKind(kind: Int) = addUnique(RootKindTag.assemble(kind)) + +fun TagArrayBuilder.rootKind(id: ExternalId) = addUnique(RootKindTag.assemble(id)) + +fun TagArrayBuilder.rootAuthor( + pubKey: HexKey, + relay: String?, +) = add(RootAuthorTag.assemble(pubKey, relay)) + +fun TagArrayBuilder.replyAddress( + addressId: String, + relayHint: String?, +) = addUnique(ReplyAddressTag.assemble(addressId, relayHint)) + +fun TagArrayBuilder.replyEvent( + eventId: String, + relayHint: String?, + pubkey: String?, +) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey)) + +fun TagArrayBuilder.replyExternalIdentity(id: ExternalId) = addAll(ReplyIdentifierTag.assemble(id)) + +fun TagArrayBuilder.replyKind(kind: String) = addUnique(ReplyKindTag.assemble(kind)) + +fun TagArrayBuilder.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind)) + +fun TagArrayBuilder.replyKind(id: ExternalId) = addUnique(RootKindTag.assemble(id)) + +fun TagArrayBuilder.replyAuthor( + pubKey: HexKey, + relay: String?, +) = add(ReplyAuthorTag.assemble(pubKey, relay)) + +fun TagArrayBuilder.notify(list: List) = pTags(list) + +fun TagArrayBuilder.notify(pubkey: PTag) = pTag(pubkey) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt new file mode 100644 index 0000000000..9e930bbf3a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.valueIfMatches +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +@Immutable +class ReplyAddressTag( + val addressId: String, + val relay: String? = null, +) { + fun toTagArray() = assemble(addressId, relay) + + companion object { + const val TAG_NAME = "a" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parse(tag: Array): ReplyAddressTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return ReplyAddressTag(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseAddress(tag: Array) = tag.valueIfMatches(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parseAsHint(tag: Array): AddressHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length == 64 || !tag[1].contains(':') || tag[2].isEmpty()) return null + return AddressHint(tag[1], tag[2]) + } + + @JvmStatic + fun assemble( + addressId: HexKey, + relay: String?, + ) = arrayOfNotNull(TAG_NAME, addressId, relay) + + @JvmStatic + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: String?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt new file mode 100644 index 0000000000..1f647c2d55 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +@Immutable +data class ReplyAuthorTag( + override val pubKey: HexKey, + override val relayHint: String? = null, +) : PubKeyReferenceTag { + fun toTagArray() = assemble(pubKey, relayHint) + + companion object { + const val TAG_NAME = "p" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parse(tag: Tag): ReplyAuthorTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[1].length != 64) return null + return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseKey(tag: Tag): HexKey? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[1].length != 64) return null + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length != 64 || tag[2].isEmpty()) return null + return PubKeyHint(tag[1], tag[2]) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: String?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt new file mode 100644 index 0000000000..77f68fbc42 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.valueIfMatches +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +@Immutable +class ReplyEventTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: String?, pubkey: String?) : this(EventReference(eventId, relayHint, pubkey)) + + fun toTagArray() = assemble(ref) + + companion object { + const val TAG_NAME = "e" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun isTagged( + tag: Array, + eventId: String, + ) = tag.match(TAG_NAME, eventId, TAG_SIZE) + + @JvmStatic + fun isIn( + tag: Array, + eventIds: Set, + ) = tag.match(TAG_NAME, eventIds, TAG_SIZE) + + @JvmStatic + fun parse(tag: Array): ReplyEventTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return ReplyEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + } + + @JvmStatic + fun parseKey(tag: Array) = tag.valueIfMatches(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parseValidKey(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[1].length != 64) return null + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length != 64 || tag[2].isEmpty()) return null + return EventIdHint(tag[1], tag[2]) + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: String?, + pubkey: String?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay, pubkey) + + @JvmStatic + fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt new file mode 100644 index 0000000000..9926fa8ff1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.valueIfMatches +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +@Immutable +class ReplyIdentifierTag { + companion object { + const val TAG_NAME = "i" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parse(tag: Tag) = tag.valueIfMatches(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun assemble( + identity: String, + hint: String?, + ) = arrayOfNotNull(TAG_NAME, identity, hint) + + @JvmStatic + fun assemble(id: ExternalId): List> = + when (id) { + is GeohashId -> GeoHash.geoMipMap(id.geohash).map { assemble(it, id.hint) } + else -> listOf(assemble(id.toScope(), id.hint())) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt new file mode 100644 index 0000000000..f3a0028ed3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.matchAndHasValue +import com.vitorpamplona.quartz.nip01Core.core.valueToIntIfMatches +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId + +class ReplyKindTag { + companion object { + const val TAG_NAME = "k" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun isTagged(tag: Tag) = tag.matchAndHasValue(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun isTagged( + tag: Tag, + kind: String, + ) = tag.match(TAG_NAME, kind, TAG_SIZE) + + @JvmStatic + fun isIn( + tag: Tag, + kinds: Set, + ) = tag.match(TAG_NAME, kinds, TAG_SIZE) + + @JvmStatic + fun parse(tag: Tag) = tag.valueToIntIfMatches(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun assemble(kind: String) = arrayOf(TAG_NAME, kind) + + @JvmStatic + fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString()) + + @JvmStatic + fun assemble(id: ExternalId) = RootKindTag.assemble(id.toKind()) + + @JvmStatic + fun assemble(kinds: List): List = kinds.map { assemble(it) } + + @JvmStatic + fun assemble(kinds: Set): List = kinds.map { assemble(it) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt new file mode 100644 index 0000000000..3241fbfc6a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.valueIfMatches +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +class RootAddressTag( + val addressId: String, + val relay: String? = null, +) { + fun toTagArray() = assemble(addressId, relay) + + companion object { + const val TAG_NAME = "A" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun isTagged( + tag: Array, + addressId: String, + ) = tag.match(TAG_NAME, addressId, TAG_SIZE) + + @JvmStatic + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.match(TAG_NAME, addressIds, TAG_SIZE) + + @JvmStatic + fun parse(tag: Array): RootAddressTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return RootAddressTag(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) = tag.valueIfMatches(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parseAsHint(tag: Array): AddressHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length == 64 || !tag[1].contains(':') || tag[2].isEmpty()) return null + return AddressHint(tag[1], tag[2]) + } + + @JvmStatic + fun assemble( + addressId: HexKey, + relay: String?, + ) = arrayOfNotNull(TAG_NAME, addressId, relay) + + @JvmStatic + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: String?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt new file mode 100644 index 0000000000..dcae0d9f93 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.PUBKEY_LENGTH +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@Immutable +data class RootAuthorTag( + override val pubKey: HexKey, + override val relayHint: String? = null, +) : PubKeyReferenceTag { + fun toTagArray() = assemble(pubKey, relayHint) + + companion object { + const val TAG_NAME = "P" + + @JvmStatic + fun match(tag: Tag) = tag.size > 1 && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Tag): ReplyAuthorTag? { + if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].length != PUBKEY_LENGTH) return null + return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parse3(tag: Tag): ReplyAuthorTag? { + ensure(tag.size >= 2) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == PUBKEY_LENGTH) { return null } + return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseKey(tag: Tag): HexKey? { + if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].length != PUBKEY_LENGTH) return null + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length != PUBKEY_LENGTH || tag[2].isEmpty()) return null + return PubKeyHint(tag[1], tag[2]) + } + + @OptIn(ExperimentalContracts::class) + inline fun ensure( + condition: Boolean, + exit: () -> Nothing, + ) { + contract { returns() implies condition } + if (!condition) exit() + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: String?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt new file mode 100644 index 0000000000..c0d546f929 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.valueIfMatches +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +@Immutable +class RootEventTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: String?, pubkey: String?) : this(EventReference(eventId, relayHint, pubkey)) + + fun toTagArray() = assemble(ref) + + companion object { + const val TAG_NAME = "E" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun isTagged( + tag: Array, + eventId: String, + ) = tag.match(TAG_NAME, eventId, TAG_SIZE) + + @JvmStatic + fun isIn( + tag: Array, + eventIds: Set, + ) = tag.match(TAG_NAME, eventIds, TAG_SIZE) + + @JvmStatic + fun parse(tag: Array): RootEventTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return RootEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + } + + @JvmStatic + fun parseKey(tag: Array) = tag.valueIfMatches(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parseValidKey(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[1].length != 64) return null + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + if (tag.size < 3 || tag[0] != TAG_NAME || tag[1].length != 64 || tag[2].isEmpty()) return null + return EventIdHint(tag[1], tag[2]) + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: String?, + pubkey: String?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay, pubkey) + + @JvmStatic + fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt new file mode 100644 index 0000000000..34aa37a9d6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.valueIfMatches +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +@Immutable +class RootIdentifierTag { + companion object { + const val TAG_NAME = "I" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parse(tag: Tag) = tag.valueIfMatches(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun assemble( + identity: String, + hint: String?, + ) = arrayOfNotNull(TAG_NAME, identity, hint) + + @JvmStatic + fun assemble(id: ExternalId): List> = + when (id) { + is GeohashId -> GeoHash.geoMipMap(id.geohash).map { assemble(it, id.hint) } + else -> listOf(assemble(id.toScope(), id.hint())) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt new file mode 100644 index 0000000000..bbbe5861d5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip22Comments.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.valueIfMatches +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId + +class RootKindTag { + companion object { + const val TAG_NAME = "K" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parse(tag: Tag) = tag.valueIfMatches(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun assemble(kind: String): Tag = arrayOf(TAG_NAME, kind) + + @JvmStatic + fun assemble(kind: Int): Tag = assemble(kind.toString()) + + @JvmStatic + fun assemble(id: ExternalId) = assemble(id.toKind()) + + @JvmStatic + fun assemble(kinds: List): List = kinds.map { assemble(it) } + + @JvmStatic + fun assemble(kinds: Set): List = kinds.map { assemble(it) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt index 38dc910b98..5136ec480b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt @@ -21,15 +21,28 @@ package com.vitorpamplona.quartz.nip23LongContent import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID @Immutable class LongTextNoteEvent( @@ -39,47 +52,58 @@ class LongTextNoteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), - AddressableEvent { +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressableEvent, + EventHintProvider, + PubKeyHintProvider, + AddressHintProvider, + RootScope { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun eventHints() = tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = tags.mapNotNull(QTag::parseAddressAsHint) + override fun dTag() = tags.dTag() - override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) - override fun addressTag() = ATag.assembleATagId(kind, pubKey, dTag()) + override fun address() = Address(kind, pubKey, dTag()) + + override fun addressTag() = Address.assemble(kind, pubKey, dTag()) fun topics() = hashtags() - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - fun publishedAt() = - try { - tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() - } catch (_: Exception) { - null - } + fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) companion object { const val KIND = 30023 - fun create( - msg: String, - title: String?, - replyTos: List?, - mentions: List?, - signer: NostrSigner, + fun build( + description: String, + title: String, + summary: String? = null, + image: String? = null, + publishedAt: Long? = null, + dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), - onReady: (LongTextNoteEvent) -> Unit, - ) { - 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(AltTagSerializer.toTagArray("Blog post: $title")) - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt("Blog post: $title") + + title(title) + summary?.let { summary(it) } + image?.let { image(it) } + publishedAt?.let { publishedAt(it) } + + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..bcbbf782c1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip23LongContent + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.publishedAt(publishedAt: Long) = addUnique(PublishedAtTag.assemble(publishedAt)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/ImageTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/ImageTag.kt new file mode 100644 index 0000000000..d7e74286ea --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/ImageTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip23LongContent.tags + +class ImageTag { + companion object { + const val TAG_NAME = "image" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt new file mode 100644 index 0000000000..671348f27b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip23LongContent.tags + +class PublishedAtTag { + companion object { + const val TAG_NAME = "published_at" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Long? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/SummaryTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/SummaryTag.kt new file mode 100644 index 0000000000..1e04d3b56f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/SummaryTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip23LongContent.tags + +class SummaryTag { + companion object { + const val TAG_NAME = "summary" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/TitleTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/TitleTag.kt new file mode 100644 index 0000000000..409c4eb1d2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/TitleTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip23LongContent.tags + +class TitleTag { + companion object { + const val TAG_NAME = "title" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt index d4abf954ca..6abac57172 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt @@ -21,11 +21,23 @@ package com.vitorpamplona.quartz.nip25Reactions import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emoji import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -36,69 +48,60 @@ class ReactionEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + fun originalPost() = tags.mapNotNull(ETag::parseId) + + fun originalAuthor() = tags.mapNotNull(PTag::parseKey) companion object { const val KIND = 7 + const val LIKE = "+" + const val DISLIKE = "-" - fun createWarning( - originalNote: Event, - signer: NostrSigner, + fun like( + reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), - onReady: (ReactionEvent) -> Unit, - ) = create("\u26A0\uFE0F", originalNote, signer, createdAt, onReady) + ) = build(LIKE, reactedTo, createdAt) - fun createLike( - originalNote: Event, - signer: NostrSigner, + fun dislike( + reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), - onReady: (ReactionEvent) -> Unit, - ) = create("+", originalNote, signer, createdAt, onReady) + ) = build(DISLIKE, reactedTo, createdAt) - fun create( - content: String, - originalNote: Event, - signer: NostrSigner, + fun build( + reaction: String, + reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), - onReady: (ReactionEvent) -> Unit, - ) { - var tags = - listOf( - arrayOf("e", originalNote.id), - arrayOf("p", originalNote.pubKey), - arrayOf("k", originalNote.kind.toString()), - ) - if (originalNote is AddressableEvent) { - tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) + ) = eventTemplate(KIND, reaction, createdAt) { + eTag(reactedTo.toETag()) + if (reactedTo.event is AddressableEvent) { + aTag(reactedTo.event.aTag(reactedTo.relay)) } - - return signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + pTag(reactedTo.event.pubKey, reactedTo.relay) + kind(reactedTo.event.kind) } - fun create( - emojiUrl: EmojiUrl, - originalNote: Event, - signer: NostrSigner, + fun build( + reaction: EmojiUrlTag, + reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), - onReady: (ReactionEvent) -> Unit, - ) { - val content = ":${emojiUrl.code}:" - - var tags = - arrayOf( - arrayOf("e", originalNote.id), - arrayOf("p", originalNote.pubKey), - arrayOf("emoji", emojiUrl.code, emojiUrl.url), - ) - - if (originalNote is AddressableEvent) { - tags += arrayOf(arrayOf("a", originalNote.address().toTag())) + ) = eventTemplate(KIND, reaction.toContentEncode(), createdAt) { + eTag(reactedTo.toETag()) + if (reactedTo.event is AddressableEvent) { + aTag(reactedTo.event.aTag(reactedTo.relay)) } - - signer.sign(createdAt, KIND, tags, content, onReady) + pTag(reactedTo.event.pubKey, reactedTo.relay) + kind(reactedTo.event.kind) + emoji(reaction) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelCreateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelCreateEvent.kt deleted file mode 100644 index 717f792934..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelCreateEvent.kt +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip28PublicChat - -import android.util.Log -import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class ChannelCreateEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun channelInfo(): ChannelData = - try { - EventMapper.mapper.readValue(content) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelData(null, null, null) - } - - companion object { - const val KIND = 40 - - fun create( - name: String?, - about: String?, - picture: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelCreateEvent) -> Unit, - ) = create( - ChannelData( - name, - about, - picture, - ), - signer, - createdAt, - onReady, - ) - - fun create( - channelInfo: ChannelData?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelCreateEvent) -> Unit, - ) { - val content = - try { - if (channelInfo != null) { - EventMapper.mapper.writeValueAsString(channelInfo) - } else { - "" - } - } catch (t: Throwable) { - Log.e("ChannelCreateEvent", "Couldn't parse channel information", t) - "" - } - - val tags = - arrayOf( - AltTagSerializer.toTagArray("Public chat creation event ${channelInfo?.name?.let { "about $it" }}"), - ) - - signer.sign(createdAt, KIND, tags, content, onReady) - } - } - - @Immutable data class ChannelData( - val name: String?, - val about: String?, - val picture: String?, - ) -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt index 4eef28c7d3..e892b6f8d0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.quartz.nip28PublicChat import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -262,7 +262,7 @@ class ChannelListEvent( if (tags.any { it.size > 1 && it[0] == "alt" }) { tags } else { - tags + AltTagSerializer.toTagArray(ALT) + tags + AltTag.assemble(ALT) } signer.sign(createdAt, KIND, newTags, content, onReady) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelMessageEvent.kt deleted file mode 100644 index 9b8d8ccb88..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelMessageEvent.kt +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip28PublicChat - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class ChannelMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), - IsInPublicChatChannel { - override fun channel() = - tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - - override fun markedReplyTos() = super.markedReplyTos().filter { it != channel() } - - override fun unMarkedReplyTos() = super.unMarkedReplyTos().filter { it != channel() } - - companion object { - const val KIND = 42 - const val ALT = "Public chat message" - - fun create( - message: String, - channel: String, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - directMentions: Set = emptySet(), - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - isDraft: Boolean, - onReady: (ChannelMessageEvent) -> Unit, - ) { - val tags = - mutableListOf( - arrayOf("e", channel, "", "root"), - ) - mentions?.forEach { tags.add(arrayOf("p", it)) } - replyTos?.forEach { - if (it in directMentions) { - tags.add(arrayOf("q", it)) - } else { - tags.add(arrayOf("e", it)) - } - } - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) - } - geohash?.let { tags.addAll(geohashMipMap(it)) } - imetas?.forEach { - tags.add(Nip92MediaAttachments.createTag(it)) - } - emojis?.forEach { tags.add(it.toTagArray()) } - tags.add( - AltTagSerializer.toTagArray(ALT), - ) - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), message, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) - } - } - } -} - -interface IsInPublicChatChannel { - fun channel(): String? -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelMetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelMetadataEvent.kt deleted file mode 100644 index 137d39136f..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelMetadataEvent.kt +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip28PublicChat - -import android.util.Log -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class ChannelMetadataEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - IsInPublicChatChannel { - override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - - fun channelInfo() = - try { - EventMapper.mapper.readValue(content, ChannelCreateEvent.ChannelData::class.java) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelCreateEvent.ChannelData(null, null, null) - } - - companion object { - const val KIND = 41 - const val ALT = "This is a public chat definition update" - - fun create( - name: String?, - about: String?, - picture: String?, - originalChannelIdHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelMetadataEvent) -> Unit, - ) { - create( - ChannelCreateEvent.ChannelData( - name, - about, - picture, - ), - originalChannelIdHex, - signer, - createdAt, - onReady, - ) - } - - fun create( - newChannelInfo: ChannelCreateEvent.ChannelData?, - originalChannelIdHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelMetadataEvent) -> Unit, - ) { - val content = - if (newChannelInfo != null) { - EventMapper.mapper.writeValueAsString(newChannelInfo) - } else { - "" - } - - val tags = - listOf( - arrayOf("e", originalChannelIdHex, "", "root"), - AltTagSerializer.toTagArray("Public chat update to ${newChannelInfo?.name}"), - ) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt new file mode 100644 index 0000000000..fc7f93afd6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.admin + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ChannelCreateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = channelInfo().relays?.map { EventIdHint(id, it) } ?: emptyList() + + fun channelInfo() = ChannelData.parse(content) ?: ChannelData() + + companion object { + const val KIND = 40 + + fun build( + name: String?, + about: String?, + picture: String?, + relays: List?, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(ChannelData(name, about, picture, relays), createdAt, initializer) + + fun build( + data: ChannelData, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, data.toContent(), createdAt) { + alt("Public chat creation event ${data.name?.let { "about $it" }}") + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt similarity index 54% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt index 27fa3e37ce..1c4c3e0d8e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt @@ -18,13 +18,20 @@ * AN ACTION OF CONTRACT, TORT OR 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 +package com.vitorpamplona.quartz.nip28PublicChat.admin import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTags +import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds +import com.vitorpamplona.quartz.nip28PublicChat.base.BasePublicChatEvent +import com.vitorpamplona.quartz.nip28PublicChat.base.channel +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,32 +42,27 @@ class ChannelHideMessageEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - IsInPublicChatChannel { - override fun channel() = - tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) +) : BasePublicChatEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) - fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun eventsToHide() = tags.taggedEventIds() companion object { const val KIND = 43 const val ALT = "Hide message instruction for public chats" - fun create( + fun build( reason: String, - messagesToHide: List?, - signer: NostrSigner, + messagesToHide: List, + channel: EventHintBundle, createdAt: Long = TimeUtils.now(), - onReady: (ChannelHideMessageEvent) -> Unit, - ) { - val tags = - ( - messagesToHide?.map { arrayOf("e", it) }?.toTypedArray() - ?: emptyArray() - ) + arrayOf(AltTagSerializer.toTagArray(ALT)) - - signer.sign(createdAt, KIND, tags, reason, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT) + channel(channel) + eTags(messagesToHide) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt new file mode 100644 index 0000000000..bc98fc090a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.admin + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip28PublicChat.base.BasePublicChatEvent +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip28PublicChat.base.channel +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ChannelMetadataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BasePublicChatEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = channelInfo().relays?.map { EventIdHint(id, it) } ?: emptyList() + + fun channelInfo() = ChannelData.parse(content) ?: ChannelData() + + companion object { + const val KIND = 41 + const val ALT = "This is a public chat definition update" + + fun build( + name: String?, + about: String?, + picture: String?, + relays: List?, + channel: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(ChannelData(name, about, picture, relays), channel, createdAt, initializer) + + fun build( + name: String?, + about: String?, + picture: String?, + relays: List?, + channel: ETag, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(ChannelData(name, about, picture, relays), channel, createdAt, initializer) + + fun build( + data: ChannelData, + channel: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, data.toContent(), createdAt) { + alt("Public chat update to ${data.name}") + channel(channel) + initializer() + } + + fun build( + data: ChannelData, + channel: ETag, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, data.toContent(), createdAt) { + alt("Public chat update to ${data.name}") + channel(channel) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelMuteUserEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt similarity index 54% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelMuteUserEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt index d5b39ce35d..2211dbd176 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelMuteUserEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt @@ -18,13 +18,20 @@ * AN ACTION OF CONTRACT, TORT OR 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 +package com.vitorpamplona.quartz.nip28PublicChat.admin import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds +import com.vitorpamplona.quartz.nip28PublicChat.base.BasePublicChatEvent +import com.vitorpamplona.quartz.nip28PublicChat.base.channel +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,33 +42,27 @@ class ChannelMuteUserEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - IsInPublicChatChannel { - override fun channel() = - tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) +) : BasePublicChatEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun usersToMute() = tags.taggedUserIds() companion object { const val KIND = 44 const val ALT = "Mute user instruction for public chats" - fun create( + fun build( reason: String, - usersToMute: List?, - signer: NostrSigner, + usersToMute: List, + channel: EventHintBundle, createdAt: Long = TimeUtils.now(), - onReady: (ChannelMuteUserEvent) -> Unit, - ) { - val content = reason - val tags = - ( - usersToMute?.map { arrayOf("p", it) }?.toTypedArray() - ?: emptyArray() - ) + arrayOf(AltTagSerializer.toTagArray(ALT)) - - signer.sign(createdAt, KIND, tags, content, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT) + channel(channel) + pTags(usersToMute) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/BasePublicChatEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/BasePublicChatEvent.kt new file mode 100644 index 0000000000..4a76981fa4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/BasePublicChatEvent.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.base + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag + +@Immutable +open class BasePublicChatEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, kind, tags, content, sig), + IsInPublicChatChannel { + fun markedRoot() = tags.firstNotNullOfOrNull(MarkedETag::parseRoot) + + fun unmarkedRoot() = tags.firstNotNullOfOrNull(MarkedETag::parseUnmarkedRoot) + + override fun channel() = markedRoot() ?: unmarkedRoot() + + override fun channelId() = channel()?.eventId +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt new file mode 100644 index 0000000000..ea1139c71a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.base + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper + +@Immutable +data class ChannelData( + val name: String? = null, + val about: String? = null, + val picture: String? = null, + val relays: List? = null, +) { + fun toContent() = assemble(this) + + companion object { + fun parse(content: String): ChannelData? = + try { + EventMapper.mapper.readValue(content) + } catch (e: Exception) { + Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) + ChannelData(null, null, null, null) + } + + fun assemble(data: ChannelData) = EventMapper.mapper.writeValueAsString(data) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/IsInPublicChatChannel.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/IsInPublicChatChannel.kt new file mode 100644 index 0000000000..170f3d5411 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/IsInPublicChatChannel.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.base + +import com.vitorpamplona.quartz.nip01Core.core.IEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag + +interface IsInPublicChatChannel : IEvent { + fun channel(): MarkedETag? + + fun channelId(): String? +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..cc8b85be99 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/TagArrayBuilderExt.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.base + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent + +fun TagArrayBuilder.channel(rep: MarkedETag) = add(rep.toTagArray()) + +fun TagArrayBuilder.channel(rep: ETag) = add(MarkedETag.assemble(rep.eventId, rep.relay, MarkedETag.MARKER.ROOT, rep.author)) + +fun TagArrayBuilder.channel(rep: EventHintBundle) = channel(rep.toMarkedETag(MarkedETag.MARKER.ROOT)) + +fun TagArrayBuilder.reply(rep: EventHintBundle) = add(rep.toMarkedETag(MarkedETag.MARKER.REPLY).toTagArray()) + +fun TagArrayBuilder.notify(list: List) = pTags(list) + +fun TagArrayBuilder.notify(pubkey: PTag) = pTag(pubkey) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt new file mode 100644 index 0000000000..4c5c418e4c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.message + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel +import com.vitorpamplona.quartz.nip28PublicChat.base.channel +import com.vitorpamplona.quartz.nip28PublicChat.base.reply +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ChannelMessageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + IsInPublicChatChannel { + override fun channel() = markedRoot() ?: unmarkedRoot() + + override fun channelId() = channel()?.eventId + + override fun markedReplyTos() = super.markedReplyTos().filter { it != channelId() } + + override fun unmarkedReplyTos() = super.unmarkedReplyTos().filter { it != channelId() } + + companion object { + const val KIND = 42 + const val ALT = "Public chat message" + + fun reply( + post: String, + replyingTo: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + replyingTo.event.channel()?.let { channel(it) } + reply(replyingTo) + initializer() + } + + fun message( + post: String, + channel: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + channel(channel) + initializer() + } + + fun message( + post: String, + channel: ETag, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + channel(channel) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/CustomEmoji.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/CustomEmoji.kt index d8fb3323a9..8cf13edce8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/CustomEmoji.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/CustomEmoji.kt @@ -36,7 +36,7 @@ class CustomEmoji { allTags: ImmutableListOfLists?, ): Boolean { if (allTags == null) return false - if (allTags.lists.any { it.size > 2 && it[0] == "emoji" }) return true + if (allTags.lists.any { it.size > 2 && it[0] == EmojiUrlTag.TAG_NAME }) return true return input.contains(":") } @@ -48,7 +48,7 @@ class CustomEmoji { return input.contains(":") } - fun createEmojiMap(tags: ImmutableListOfLists): Map = tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } + fun createEmojiMap(tags: ImmutableListOfLists): Map = tags.lists.filter { it.size > 2 && it[0] == EmojiUrlTag.TAG_NAME }.associate { ":${it[1]}:" to it[2] } fun findAllEmojis(input: String): List { val matcher = customEmojiPattern.matcher(input) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt similarity index 56% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt index db83d02c95..f614149177 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt @@ -21,64 +21,35 @@ package com.vitorpamplona.quartz.nip30CustomEmoji import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class EmojiPackEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30030 - const val ALT = "Emoji pack" - - fun create( - name: String = "", - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (EmojiPackEvent) -> Unit, - ) { - val content = "" - - val tags = mutableListOf>() - tags.add(arrayOf("d", name)) - tags.add(AltTagSerializer.toTagArray(ALT)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } -} - -@Immutable -data class EmojiUrl( +data class EmojiUrlTag( val code: String, val url: String, ) { fun encode(): String = ":$code:$url" + fun toContentEncode(): String = contentEncode(code) + fun toTagArray() = arrayOf("emoji", code, url) companion object { - fun decode(encodedEmojiSetup: String): EmojiUrl? { + const val TAG_NAME = "emoji" + + fun contentEncode(code: String): String = ":$code:" + + fun decode(encodedEmojiSetup: String): EmojiUrlTag? { val emojiParts = encodedEmojiSetup.split(":", limit = 3) return if (emojiParts.size > 2) { - EmojiUrl(emojiParts[1], emojiParts[2]) + EmojiUrlTag(emojiParts[1], emojiParts[2]) } else { null } } - fun parse(tag: Array): EmojiUrl? = + fun parse(tag: Array): EmojiUrlTag? = if (tag.size > 2 && tag[0] == "emoji") { - EmojiUrl(tag[1], tag[2]) + EmojiUrlTag(tag[1], tag[2]) } else { null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EventExt.kt index bf48b06efb..dee767b073 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EventExt.kt @@ -21,6 +21,5 @@ package com.vitorpamplona.quartz.nip30CustomEmoji import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.mapTagged -fun Event.taggedEmojis() = tags.mapTagged("emoji") { EmojiUrl.parse(it) } +fun Event.taggedEmojis() = tags.emojis() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..9a5067f5e8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip30CustomEmoji + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.emoji(tag: EmojiUrlTag) = add(tag.toTagArray()) + +fun TagArrayBuilder.emojis(tags: List) = addAll(tags.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayExt.kt new file mode 100644 index 0000000000..fe638929f2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip30CustomEmoji + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.mapTagged + +fun TagArray.emojis() = this.mapTagged(EmojiUrlTag.TAG_NAME) { EmojiUrlTag.parse(it) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/AppRecommendationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt similarity index 57% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/AppRecommendationEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt index 1c24f0314f..a14048f316 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/AppRecommendationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt @@ -18,41 +18,43 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip89AppHandlers +package com.vitorpamplona.quartz.nip30CustomEmoji.pack import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.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.utils.TimeUtils +import java.util.UUID @Immutable -class AppRecommendationEvent( +class EmojiPackEvent( id: HexKey, pubKey: HexKey, createdAt: Long, tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { ATag.parse(it[1], it.getOrNull(2)) } - +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { - const val KIND = 31989 - const val ALT = "App recommendations by the author" + const val KIND = 30030 + const val ALT_DESCRIPTION = "Emoji pack" - fun create( - signer: NostrSigner, + fun build( + name: String, + dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { - val tags = - arrayOf( - AltTagSerializer.toTagArray(ALT), - ) - signer.sign(createdAt, KIND, tags, "", onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTag) + name(name) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt new file mode 100644 index 0000000000..a49bb75161 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip30CustomEmoji.selection + +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.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventUpdate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class EmojiPackSelectionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + fun emojiPacks() = tags.mapNotNull(ATag::parseAddress) + + fun emojiPackIds() = tags.mapNotNull(ATag::parseAddressId) + + companion object { + const val KIND = 10030 + const val ALT_DESCRIPTION = "Emoji selection" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressTag(pubKey: HexKey) = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + fun add( + currentSelection: EmojiPackSelectionEvent, + packToAdd: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventUpdate(currentSelection, createdAt) { + pack(packToAdd.toATag()) + initializer() + } + + fun remove( + currentSelection: EmojiPackSelectionEvent, + packToRemove: EmojiPackEvent, + createdAt: Long = TimeUtils.now(), + updater: TagArrayBuilder.() -> Unit = {}, + ) = eventUpdate(currentSelection, createdAt) { + removePack(packToRemove.aTag(null)) + updater() + } + + fun build( + listOfEmojiPacks: List>, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + packs(listOfEmojiPacks.map { it.toATag() }) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..6a702f12a1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip30CustomEmoji.selection + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTags +import com.vitorpamplona.quartz.nip01Core.tags.addressables.removeATag + +fun TagArrayBuilder.pack(tag: ATag) = aTag(tag) + +fun TagArrayBuilder.packs(tags: List) = aTags(tags) + +fun TagArrayBuilder.removePack(tag: ATag) = removeATag(tag) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt new file mode 100644 index 0000000000..b28d374655 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip31Alts + +class AltTag { + companion object { + const val TAG_NAME = "alt" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(altDescriptor: String = "") = arrayOf(TAG_NAME, altDescriptor) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/EventExt.kt index c83d28845b..b23763ad48 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/EventExt.kt @@ -21,8 +21,5 @@ package com.vitorpamplona.quartz.nip31Alts import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -val ALT_TAG = "alt" - -fun Event.alt() = tags.firstTagValue(ALT_TAG) +fun Event.alt() = tags.alt() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..87bb2b59b6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip31Alts + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.alt(altDescriptor: String) = addUnique(AltTag.assemble(altDescriptor)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTagSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayExt.kt similarity index 87% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTagSerializer.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayExt.kt index a696f58138..a9326360a2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTagSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayExt.kt @@ -20,9 +20,6 @@ */ package com.vitorpamplona.quartz.nip31Alts -class AltTagSerializer { - companion object { - @JvmStatic - fun toTagArray(altDescriptor: String = "") = arrayOf(ALT_TAG, altDescriptor) - } -} +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.alt() = firstNotNullOfOrNull(AltTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitIssueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitIssueEvent.kt deleted file mode 100644 index 7fb1977c6f..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitIssueEvent.kt +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip34Git - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip19Bech32.parse -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class GitIssueEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - private fun innerRepository() = - tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } - ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } - - private fun repositoryHex() = innerRepository()?.getOrNull(1) - - fun rootIssueOrPatch() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - - fun repository() = - innerRepository()?.let { - if (it.size > 1) { - val aTagValue = it[1] - val relay = it.getOrNull(2) - - ATag.parse(aTagValue, relay) - } else { - null - } - } - - companion object { - const val KIND = 1621 - const val ALT = "A Git Issue" - - fun create( - patch: String, - createdAt: Long = TimeUtils.now(), - signer: NostrSigner, - onReady: (GitIssueEvent) -> Unit, - ) { - val content = patch - val tags = - mutableListOf( - arrayOf(), - ) - - tags.add(AltTagSerializer.toTagArray(ALT)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitReplyEvent.kt deleted file mode 100644 index 74ce3b189d..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitReplyEvent.kt +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip34Git - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.buildHashtagTags -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip10Notes.content.buildUrlRefs -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip10Notes.positionalMarkedTags -import com.vitorpamplona.quartz.nip19Bech32.parse -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class GitReplyEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - private fun innerRepository() = - tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } - ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } - - private fun repositoryHex() = innerRepository()?.getOrNull(1) - - fun repository() = - innerRepository()?.let { - if (it.size > 1) { - val aTagValue = it[1] - val relay = it.getOrNull(2) - - ATag.parse(aTagValue, relay) - } else { - null - } - } - - fun rootIssueOrPath() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - - companion object { - const val KIND = 1622 - const val ALT = "A Git Reply" - - fun create( - patch: String, - createdAt: Long = TimeUtils.now(), - signer: NostrSigner, - onReady: (GitReplyEvent) -> Unit, - ) { - val content = patch - val tags = - mutableListOf( - arrayOf(), - ) - - tags.add(AltTagSerializer.toTagArray(ALT)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - - fun create( - msg: String, - replyTos: List? = null, - mentions: List? = null, - addresses: List? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - replyingTo: String? = null, - root: String? = null, - directMentions: Set = emptySet(), - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - forkedFrom: Event? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - isDraft: Boolean, - onReady: (GitReplyEvent) -> Unit, - ) { - val tags = mutableListOf>() - replyTos?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "e", - root = root, - replyingTo = replyingTo, - directMentions = directMentions, - forkedFrom = forkedFrom?.id, - ), - ) - } - mentions?.forEach { - if (it in directMentions) { - tags.add(arrayOf("p", it, "", "mention")) - } else { - tags.add(arrayOf("p", it)) - } - } - replyTos?.forEach { - if (it in directMentions) { - tags.add(arrayOf("q", it)) - } - } - addresses - ?.map { it.toTag() } - ?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "a", - root = root, - replyingTo = replyingTo, - directMentions = directMentions, - forkedFrom = (forkedFrom as? AddressableEvent)?.address()?.toTag(), - ), - ) - } - tags.addAll(buildHashtagTags(findHashtags(msg))) - tags.addAll(buildUrlRefs(findURLs(msg))) - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) - } - geohash?.let { tags.addAll(geohashMipMap(it)) } - imetas?.forEach { - tags.add(Nip92MediaAttachments.createTag(it)) - } - emojis?.forEach { tags.add(it.toTagArray()) } - tags.add(AltTagSerializer.toTagArray("a git issue reply")) - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), msg, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt new file mode 100644 index 0000000000..063aafcf25 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.issue + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip14Subject.SubjectTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class GitIssueEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, + EventHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun eventHints() = tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + fun repositoryHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + fun repositoryAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + + fun repository() = tags.firstNotNullOfOrNull(ATag::parse) + + fun topics() = hashtags() + + fun subject() = tags.firstNotNullOfOrNull(SubjectTag::parse) + + companion object { + const val KIND = 1621 + const val ALT_DESCRIPTION = "A Git Issue" + + fun build( + subject: String, + content: String, + repository: EventHintBundle, + notify: List, + topics: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + subject(subject) + repository(repository) + notify(notify) + hashtags(topics) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..1f81b3f5d9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/TagArrayBuilderExt.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.issue + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip14Subject.SubjectTag +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +fun TagArrayBuilder.repository(rep: ATag) = addUnique(rep.toATagArray()) + +fun TagArrayBuilder.repository(rep: EventHintBundle) = addUnique(rep.toATag().toATagArray()) + +fun TagArrayBuilder.notify(list: List) = pTags(list) + +fun TagArrayBuilder.subject(subject: String) = add(SubjectTag.assemble(subject)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitPatchEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt similarity index 74% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitPatchEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt index befa100b76..b9366cbd51 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitPatchEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt @@ -18,15 +18,20 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip34Git +package com.vitorpamplona.quartz.nip34Git.patch import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip19Bech32.parse -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -37,13 +42,31 @@ class GitPatchEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, + EventHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + private fun innerRepository() = tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } private fun repositoryHex() = innerRepository()?.getOrNull(1) + fun repositoryAddress() = + innerRepository()?.let { + if (it.size > 1) { + Address.parse(it[1]) + } else { + null + } + } + fun repository() = innerRepository()?.let { if (it.size > 1) { @@ -90,7 +113,7 @@ class GitPatchEvent( arrayOf(), ) - tags.add(AltTagSerializer.toTagArray(ALT)) + tags.add(AltTag.assemble(ALT)) signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt new file mode 100644 index 0000000000..430b4a597a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.reply + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip10Notes.tags.markedETags +import com.vitorpamplona.quartz.nip10Notes.tags.prepareMarkedETagsAsReplyTo +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull + +@Immutable +class GitReplyEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, + EventHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + fun repositoryHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + fun repository() = tags.firstNotNullOfOrNull(ATag::parse) + + fun rootIssueOrPatch() = tags.lastNotNullOfOrNull(MarkedETag::parseRootId) + + companion object { + const val KIND = 1622 + const val ALT_DESCRIPTION = "A Git Reply" + + fun reply( + post: String, + replyingTo: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + replyingTo.event.repository()?.let { repository(it) } + markedETags(prepareMarkedETagsAsReplyTo(replyingTo)) + + initializer() + } + + fun replyIssue( + post: String, + issue: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + issue.event.repository()?.let { repository(it) } + issue(issue) + + initializer() + } + + fun replyPatch( + post: String, + patch: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + patch.event.repository()?.let { repository(it) } + patch(patch) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..4965013299 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/TagArrayBuilderExt.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.reply + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +fun TagArrayBuilder.repository(rep: ATag) = addUnique(rep.toATagArray()) + +fun TagArrayBuilder.repository(rep: EventHintBundle) = addUnique(rep.toATag().toATagArray()) + +fun TagArrayBuilder.patch(rep: EventHintBundle) = addUnique(rep.toMarkedETag(MarkedETag.MARKER.ROOT).toTagArray()) + +fun TagArrayBuilder.issue(rep: EventHintBundle) = addUnique(rep.toMarkedETag(MarkedETag.MARKER.ROOT).toTagArray()) + +fun TagArrayBuilder.notify(list: List) = pTags(list) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitRepositoryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt similarity index 52% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitRepositoryEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt index 090121e352..be5f5416f7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/GitRepositoryEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt @@ -18,14 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip34Git +package com.vitorpamplona.quartz.nip34Git.repository import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent.Companion.ALT_DESCRIPTION +import com.vitorpamplona.quartz.nip34Git.repository.tags.CloneTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.WebTag import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID @Immutable class GitRepositoryEvent( @@ -36,26 +44,34 @@ class GitRepositoryEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) - fun web() = tags.firstOrNull { it.size > 1 && it[0] == "web" }?.get(1) + fun web() = tags.firstNotNullOfOrNull(WebTag::parse) - fun clone() = tags.firstOrNull { it.size > 1 && it[0] == "clone" }?.get(1) + fun clone() = tags.firstNotNullOfOrNull(CloneTag::parse) companion object { const val KIND = 30617 const val ALT = "Git Repository" - fun create( - signer: NostrSigner, + fun build( + name: String, + description: String? = null, + webUrl: String? = null, + cloneUrl: String? = null, + dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), - onReady: (GitRepositoryEvent) -> Unit, - ) { - val tags = mutableListOf>() - tags.add(AltTagSerializer.toTagArray(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTag) + name(name) + description?.let { description(it) } + webUrl?.let { webUrl(it) } + cloneUrl?.let { cloneUrl(it) } + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ffd653f6b2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.repository + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip34Git.repository.tags.CloneTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.WebTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.webUrl(webUrl: String) = addUnique(WebTag.assemble(webUrl)) + +fun TagArrayBuilder.cloneUrl(cloneUrl: String) = addUnique(CloneTag.assemble(cloneUrl)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt new file mode 100644 index 0000000000..47b1e1f18c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.repository.tags + +class CloneTag { + companion object { + const val TAG_NAME = "clone" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/DescriptionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/DescriptionTag.kt new file mode 100644 index 0000000000..4256d7efea --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/DescriptionTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.repository.tags + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/NameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/NameTag.kt new file mode 100644 index 0000000000..8fc6aff04a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/NameTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.repository.tags + +class NameTag { + companion object { + const val TAG_NAME = "name" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt new file mode 100644 index 0000000000..5c5445856f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.repository.tags + +class WebTag { + companion object { + const val TAG_NAME = "web" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/DefaultTrackers.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/DefaultTrackers.kt new file mode 100644 index 0000000000..81fb74c6a2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/DefaultTrackers.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip35Torrents + +val DEFAULT_TRACKERS = + listOf( + "http://tracker.loadpeers.org:8080/xvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX/announce", + "udp://tracker.coppersurfer.tk:6969/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://open.stealth.si:80/announce", + "udp://tracker.torrent.eu.org:451/announce", + "udp://tracker.opentrackr.org:1337", + ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..bca45954c3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TagArrayBuilderExt.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip35Torrents + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip35Torrents.tags.BtihTag +import com.vitorpamplona.quartz.nip35Torrents.tags.FileTag +import com.vitorpamplona.quartz.nip35Torrents.tags.InfoHashTag +import com.vitorpamplona.quartz.nip35Torrents.tags.TrackerTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.btih(btih: String) = addUnique(BtihTag.assemble(btih)) + +fun TagArrayBuilder.infohash(hash: String) = addUnique(InfoHashTag.assemble(hash)) + +fun TagArrayBuilder.file(tag: FileTag) = add(tag.toTagArray()) + +fun TagArrayBuilder.files(tags: List) = addAll(tags.map { it.toTagArray() }) + +fun TagArrayBuilder.tracker(uri: String) = add(TrackerTag.assemble(uri)) + +fun TagArrayBuilder.trackers(uris: List) = addAll(uris.map { TrackerTag.assemble(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt index 9c022b1509..a5cabb1342 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt @@ -21,26 +21,23 @@ package com.vitorpamplona.quartz.nip35Torrents import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.buildHashtagTags -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip10Notes.content.buildUrlRefs -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip10Notes.positionalMarkedTags -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTags +import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip10Notes.tags.positionalMarkedTags +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -51,97 +48,57 @@ class TorrentCommentEvent( tags: Array>, content: String, sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - private fun innerTorrent() = - tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" } - ?: tags.firstOrNull { it.size > 1 && it[0] == "e" } +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - fun torrent() = innerTorrent()?.getOrNull(1) + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = tags.mapNotNull(QTag::parseAddressAsHint) + + fun torrent() = tags.firstNotNullOfOrNull(MarkedETag::parseRoot) ?: tags.firstNotNullOfOrNull(ETag::parse) + + fun torrentIds() = tags.firstNotNullOfOrNull(MarkedETag::parseRootId) ?: tags.firstNotNullOfOrNull(ETag::parseId) companion object { const val KIND = 2004 - const val ALT = "Comment for a Torrent file" + const val ALT_DESCRIPTION = "Comment for a Torrent file" - fun create( + fun build( message: String, - torrent: HexKey, - replyTos: List? = null, - mentions: List? = null, - addresses: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, + torrent: EventHintBundle, + replyingTo: EventHintBundle?, createdAt: Long = TimeUtils.now(), - markAsSensitive: Boolean, - replyingTo: String? = null, - directMentions: Set = emptySet(), - zapRaiserAmount: Long?, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - forkedFrom: Event? = null, - isDraft: Boolean, - onReady: (TorrentCommentEvent) -> Unit, - ) { - val content = message - - val tags = mutableListOf>() - replyTos?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "e", - root = torrent, - replyingTo = replyingTo, - directMentions = directMentions, - forkedFrom = forkedFrom?.id, - ), - ) - } - mentions?.forEach { - if (it in directMentions) { - tags.add(arrayOf("p", it, "", "mention")) + ): EventTemplate { + val eTags = + if (replyingTo == null) { + listOfNotNull(torrent.toETag()) } else { - tags.add(arrayOf("p", it)) + replyingTo.event.taggedEvents() + replyingTo.toETag() } - } - replyTos?.forEach { - if (it in directMentions) { - tags.add(arrayOf("q", it)) - } - } - addresses - ?.map { it.toTag() } - ?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "a", - root = torrent, - replyingTo = replyingTo, - directMentions = directMentions, - forkedFrom = (forkedFrom as? AddressableEvent)?.address()?.toTag(), - ), - ) - } - tags.addAll(buildHashtagTags(findHashtags(message))) - tags.addAll(buildUrlRefs(findURLs(message))) - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } + // double check the order and erases older markers. + val sortedAndMarked = + eTags.positionalMarkedTags( + root = torrent.toETag(), + replyingTo = replyingTo?.toETag(), + forkedFrom = null, + ) - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) + return build(message, createdAt) { + eTags(sortedAndMarked) } - geohash?.let { tags.addAll(geohashMipMap(it)) } - imetas?.forEach { - tags.add(Nip92MediaAttachments.createTag(it)) - } - emojis?.forEach { tags.add(it.toTagArray()) } - tags.add(AltTagSerializer.toTagArray(ALT)) + } - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), content, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } + fun build( + post: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + alt(ALT_DESCRIPTION) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt index 5b201d6b91..6d7532c4d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt @@ -22,13 +22,23 @@ package com.vitorpamplona.quartz.nip35Torrents import android.net.Uri import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -import com.vitorpamplona.quartz.nip01Core.core.mapValues -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip35Torrents.tags.BtihTag +import com.vitorpamplona.quartz.nip35Torrents.tags.FileTag +import com.vitorpamplona.quartz.nip35Torrents.tags.InfoHashTag +import com.vitorpamplona.quartz.nip35Torrents.tags.TrackerTag +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -40,15 +50,15 @@ class TorrentEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun title() = tags.firstTagValue("title") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun btih() = tags.firstTagValue("btih") + fun btih() = tags.firstNotNullOfOrNull(BtihTag::parse) - fun x() = tags.firstTagValue("x") + fun x() = tags.firstNotNullOfOrNull(InfoHashTag::parse) - fun trackers() = tags.mapValues("tracker") + fun trackers() = tags.mapNotNull(TrackerTag::parse) - fun files() = tags.filter { it.size > 1 && it[0] == "file" }.map { TorrentFile(it[1], it.getOrNull(2)?.toLongOrNull()) } + fun files() = tags.mapNotNull(FileTag::parse) fun toMagnetLink(): String { val builder = Uri.Builder() @@ -64,64 +74,45 @@ class TorrentEvent( return builder.build().toString() } - fun totalSizeBytes(): Long = tags.filter { it.size > 1 && it[0] == "file" }.sumOf { it.getOrNull(2)?.toLongOrNull() ?: 0L } + fun totalSizeBytes(): Long = tags.sumOf { FileTag.parseBytes(it) ?: 0L } companion object { const val KIND = 2003 const val ALT_DESCRIPTION = "A torrent file" - val DEFAULT_TRACKERS = - listOf( - "http://tracker.loadpeers.org:8080/xvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX/announce", - "udp://tracker.coppersurfer.tk:6969/announce", - "udp://tracker.openbittorrent.com:6969/announce", - "udp://open.stealth.si:80/announce", - "udp://tracker.torrent.eu.org:451/announce", - "udp://tracker.opentrackr.org:1337", - ) + fun build( + description: String?, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description ?: "", createdAt) { + alt(ALT_DESCRIPTION) + initializer() + } - fun create( + fun build( title: String, btih: String, - files: List, + files: List, description: String? = null, x: String? = null, trackers: List? = null, alt: String? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, + contentWarningReason: String? = null, createdAt: Long = TimeUtils.now(), - onReady: (TorrentEvent) -> Unit, - ) { - val tags = - listOfNotNull( - arrayOf("title", title), - arrayOf("btih", btih), - x?.let { arrayOf("x", it) }, - alt?.let { arrayOf("alt", it) } ?: AltTagSerializer.toTagArray(ALT_DESCRIPTION), - sensitiveContent?.let { - if (it) { - ContentWarningSerializer.toTagArray() - } else { - null - } - }, - ) + - files.map { - if (it.bytes != null) { - arrayOf(it.fileName, it.bytes.toString()) - } else { - arrayOf(it.fileName) - } - } + - ( - trackers?.map { - arrayOf(it) - } ?: emptyList() - ) + ) = eventTemplate(KIND, description ?: "", createdAt) { + alt(alt ?: ALT_DESCRIPTION) + title(title) + btih(btih) + files(files) + trackers?.let { trackers(it) } + x?.let { infohash(it) } + contentWarningReason?.let { contentWarning(it) } - val content = description ?: "" - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + description?.let { + hashtags(findHashtags(it)) + references(findURLs(it)) + quotes(findNostrUris(it)) + } } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/BtihTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/BtihTag.kt new file mode 100644 index 0000000000..85697ae112 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/BtihTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip35Torrents.tags + +class BtihTag { + companion object { + const val TAG_NAME = "btih" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(btih: String) = arrayOf(TAG_NAME, btih) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/FileTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/FileTag.kt new file mode 100644 index 0000000000..895eb423cf --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/FileTag.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip35Torrents.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip46RemoteSigner.getOrNull + +@Immutable +class FileTag( + val fileName: String, + val bytes: Long?, +) { + fun toTagArray() = assemble(fileName, bytes) + + companion object { + const val TAG_NAME = "file" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): FileTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return FileTag(tag[1], tag.getOrNull(2)?.toLongOrNull()) + } + + @JvmStatic + fun parseBytes(tag: Array): Long? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag.getOrNull(2)?.toLongOrNull() + } + + @JvmStatic + fun assemble( + name: String, + bytes: Long?, + ) = arrayOf(TAG_NAME, name, bytes.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/InfoHashTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/InfoHashTag.kt new file mode 100644 index 0000000000..744aad94d6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/InfoHashTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip35Torrents.tags + +class InfoHashTag { + companion object { + const val TAG_NAME = "x" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(hash: String) = arrayOf(TAG_NAME, hash) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/TrackerTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/TrackerTag.kt new file mode 100644 index 0000000000..8282c36700 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/TrackerTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip35Torrents.tags + +class TrackerTag { + companion object { + const val TAG_NAME = "tracker" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(uri: String) = arrayOf(TAG_NAME, uri) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt new file mode 100644 index 0000000000..593bad3a70 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip36SensitiveContent + +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class ContentWarningTag( + val reason: String, +) { + fun countMemory(): Long = 1 * pointerSizeInBytes + reason.bytesUsedInMemory() + + fun toTagArray() = assemble(reason) + + companion object { + const val TAG_NAME = "content-warning" + + @JvmStatic + fun parse(tags: Array): ContentWarningTag { + require(tags[0] == TAG_NAME) + return ContentWarningTag(tags[1]) + } + + @JvmStatic + fun assemble(reason: String) = arrayOf(TAG_NAME, reason) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt index 1568449dc0..1262f7e76a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt @@ -22,12 +22,6 @@ package com.vitorpamplona.quartz.nip36SensitiveContent import com.vitorpamplona.quartz.nip01Core.core.Event -const val CONTENT_WARNING = "content-warning" +fun Event.isSensitive() = tags.isSensitive() -fun Event.isSensitive() = tags.any { (it.size > 0 && it[0] == CONTENT_WARNING) } - -fun Event.isSensitiveOrNSFW() = - tags.any { - (it.size > 0 && it[0] == CONTENT_WARNING) || - (it.size > 1 && it[0] == "t" && (it[1].equals("nsfw", true) || it[1].equals("nude", true))) - } +fun Event.isSensitiveOrNSFW() = tags.isSensitiveOrNSFW() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..184b8b6bdd --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip36SensitiveContent + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.contentWarning(reason: String) = add(ContentWarningTag.assemble(reason)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt new file mode 100644 index 0000000000..dc804d02ea --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip36SensitiveContent + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.isSensitive() = this.any { (it.size > 0 && it[0] == ContentWarningTag.TAG_NAME) } + +fun TagArray.isSensitiveOrNSFW() = + this.any { + (it.size > 0 && it[0] == ContentWarningTag.TAG_NAME) || + (it.size > 1 && it[0] == "t" && (it[1].equals("nsfw", true) || it[1].equals("nude", true))) + } 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 a3576c6f89..a0de590fc2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt @@ -23,17 +23,17 @@ package com.vitorpamplona.quartz.nip37Drafts import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelMessageEvent -import com.vitorpamplona.quartz.nip34Git.GitReplyEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent -import com.vitorpamplona.quartz.nip53LiveActivities.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -125,7 +125,7 @@ class DraftEvent( fun createAddressTag( pubKey: HexKey, dTag: String, - ): String = ATag.assembleATagId(KIND, pubKey, dTag) + ): String = Address.assemble(KIND, pubKey, dTag) fun create( dTag: String, @@ -175,7 +175,7 @@ class DraftEvent( onReady: (DraftEvent) -> Unit, ) { val tags = mutableListOf>() - originalNote.channel()?.let { tags.add(arrayOf("e", it)) } + originalNote.channelId()?.let { tags.add(arrayOf("e", it)) } create(dTag, originalNote, tags, signer, createdAt, onReady) } @@ -216,7 +216,7 @@ class DraftEvent( createdAt: Long = TimeUtils.now(), onReady: (DraftEvent) -> Unit, ) { - val tagsWithMarkers = originalNote.getRootScopes() + originalNote.getDirectReplies() + val tagsWithMarkers = originalNote.rootScopes() + originalNote.directReplies() create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) } 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 196bf1b08b..4ee1ba535b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.nip38UserStatus import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt index 5226596245..5787f1c73b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt @@ -20,26 +20,15 @@ */ package com.vitorpamplona.quartz.nip39ExtIdentities -import android.util.Log -import com.vitorpamplona.quartz.nip01Core.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.mapTagged +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -fun MetadataEvent.identityClaims() = - tags.mapTagged("i") { - try { - IdentityClaim.create(it[1], it[2]) - } catch (e: Exception) { - Log.e("MetadataEvent", "Can't parse identity [${it.joinToString { "," }}]", e) - null - } - } +fun MetadataEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse) -fun MetadataEvent.updateClaims( +fun MetadataEvent.replaceClaims( twitter: String?, mastodon: String?, github: String?, -): TagArray { +): List { var claims = identityClaims() // null leave as is. blank deletes it. @@ -68,5 +57,5 @@ fun MetadataEvent.updateClaims( } } - return claims.map { arrayOf("i", it.platformIdentity(), it.proof) }.toTypedArray() + return claims } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/GitHubIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/GitHubIdentity.kt index 522dc88f4a..88bbad7dd5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/GitHubIdentity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/GitHubIdentity.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip39ExtIdentities class GitHubIdentity( identity: String, proof: String, -) : IdentityClaim(identity, proof) { +) : IdentityClaimTag(identity, proof) { override fun toProofUrl() = "https://gist.github.com/$identity/$proof" override fun platform() = platform diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaim.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt similarity index 66% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaim.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt index cfe30c135b..449100c19e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaim.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.quartz.nip39ExtIdentities +import android.util.Log import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.utils.ensure @Stable -abstract class IdentityClaim( +abstract class IdentityClaimTag( val identity: String, val proof: String, ) { @@ -31,13 +33,17 @@ abstract class IdentityClaim( abstract fun platform(): String + fun toTagArray() = assemble(platformIdentity(), proof) + fun platformIdentity() = "${platform()}:$identity" companion object { + const val TAG_NAME = "i" + fun create( platformIdentity: String, proof: String, - ): IdentityClaim { + ): IdentityClaimTag { val (platform, identity) = platformIdentity.split(':') return when (platform.lowercase()) { @@ -45,8 +51,26 @@ abstract class IdentityClaim( TwitterIdentity.platform -> TwitterIdentity(identity, proof) TelegramIdentity.platform -> TelegramIdentity(identity, proof) MastodonIdentity.platform -> MastodonIdentity(identity, proof) - else -> throw IllegalArgumentException("Platform $platform not supported") + else -> UnsupportedIdentity(platform, identity, proof) } } + + fun parse(tag: Array): IdentityClaimTag? { + ensure(tag.size > 2) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2].isNotEmpty()) { return null } + return try { + create(tag[1], tag[2]) + } catch (e: Exception) { + Log.e("IdentityClaim", "Can't parse identity [${tag.joinToString { "," }}]", e) + null + } + } + + fun assemble( + platformIdentity: String, + proof: String, + ): Array = arrayOf(TAG_NAME, platformIdentity, proof) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/MastodonIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/MastodonIdentity.kt index 59975daf8c..57292e8a32 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/MastodonIdentity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/MastodonIdentity.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip39ExtIdentities class MastodonIdentity( identity: String, proof: String, -) : IdentityClaim(identity, proof) { +) : IdentityClaimTag(identity, proof) { override fun toProofUrl() = "https://$identity/$proof" override fun platform() = platform diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..4c27d610be --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip39ExtIdentities + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent + +fun TagArrayBuilder.claims(identities: List) = addAll(identities.map { it.toTagArray() }) + +fun TagArrayBuilder.twitterClaim(twitter: TwitterIdentity) = add(twitter.toTagArray()) + +fun TagArrayBuilder.mastodonClaim(mastodon: MastodonIdentity) = add(mastodon.toTagArray()) + +fun TagArrayBuilder.githubClaim(github: GitHubIdentity) = add(github.toTagArray()) + +fun TagArrayBuilder.twitterClaim(twitterUrl: String) = TwitterIdentity.parseProofUrl(twitterUrl)?.let { twitterClaim(it) } + +fun TagArrayBuilder.mastodonClaim(mastodonUrl: String) = MastodonIdentity.parseProofUrl(mastodonUrl)?.let { mastodonClaim(it) } + +fun TagArrayBuilder.githubClaim(githubUrl: String) = GitHubIdentity.parseProofUrl(githubUrl)?.let { githubClaim(it) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TelegramIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TelegramIdentity.kt index 0dff28a8f2..555196e758 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TelegramIdentity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TelegramIdentity.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip39ExtIdentities class TelegramIdentity( identity: String, proof: String, -) : IdentityClaim(identity, proof) { +) : IdentityClaimTag(identity, proof) { override fun toProofUrl() = "https://t.me/$proof" override fun platform() = platform diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TwitterIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TwitterIdentity.kt index 0f87cf517d..befdb67b50 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TwitterIdentity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TwitterIdentity.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip39ExtIdentities class TwitterIdentity( identity: String, proof: String, -) : IdentityClaim(identity, proof) { +) : IdentityClaimTag(identity, proof) { override fun toProofUrl() = "https://x.com/$identity/status/$proof" override fun platform() = platform diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/UnsupportedIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/UnsupportedIdentity.kt new file mode 100644 index 0000000000..ba55629db1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/UnsupportedIdentity.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip39ExtIdentities + +class UnsupportedIdentity( + val platform: String, + identity: String, + proof: String, +) : IdentityClaimTag(identity, proof) { + override fun toProofUrl() = "Unsupported Identity" + + override fun platform() = platform +} 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 c835c05026..9cf8af432b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.nip42RelayAuth import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.utils.TimeUtils diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentFile.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/EncryptedInfoString.kt similarity index 86% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentFile.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/EncryptedInfoString.kt index 7b346585e5..c17755d031 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentFile.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/EncryptedInfoString.kt @@ -18,12 +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.nip35Torrents +package com.vitorpamplona.quartz.nip44Encryption -import androidx.compose.runtime.Immutable - -@Immutable -class TorrentFile( - val fileName: String, - val bytes: Long?, +class EncryptedInfoString( + val ciphertext: String, + val nonce: String, + val v: Int, + val mac: String?, ) 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 c7018bac69..2ecd2b31f0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt @@ -20,36 +20,21 @@ */ package com.vitorpamplona.quartz.nip44Encryption +import android.util.Log import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip04Dm.Nip04 -import fr.acinq.secp256k1.Secp256k1 -import java.security.SecureRandom +import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo +import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 import java.util.Base64 -class Nip44( - secp256k1: Secp256k1, - random: SecureRandom, - val nip04: Nip04, -) { - public val v1 = Nip44v1(secp256k1, random) - public val v2 = Nip44v2(secp256k1, random) +object Nip44 { + val v1 = Nip44v1() + val v2 = Nip44v2() fun clearCache() { v1.clearCache() v2.clearCache() } - /** NIP 44v2 Utils */ - fun getSharedSecret( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray = v2.getConversationKey(privateKey, pubKey) - - fun computeSharedSecret( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray = v2.computeConversationKey(privateKey, pubKey) - fun encrypt( msg: String, privateKey: ByteArray, @@ -73,28 +58,28 @@ class Nip44( } } - class EncryptedInfoString( - val ciphertext: String, - val nonce: String, - val v: Int, - val mac: String?, - ) - - fun decryptNIP44FromJackson( + private fun decryptNIP44FromJackson( json: String, privateKey: ByteArray, pubKey: ByteArray, ): String? { - val info = EventMapper.mapper.readValue(json, EncryptedInfoString::class.java) + // Ignores if it is not a valid json + val info = + try { + EventMapper.mapper.readValue(json, EncryptedInfoString::class.java) + } catch (e: Exception) { + Log.e("NIP44", "Unable to parse json $json") + return null + } return when (info.v) { - Nip04.EncryptedInfo.V -> { + EncryptedInfo.V -> { val encryptedInfo = - Nip04.EncryptedInfo( + EncryptedInfo( ciphertext = Base64.getDecoder().decode(info.ciphertext), nonce = Base64.getDecoder().decode(info.nonce), ) - nip04.decrypt(encryptedInfo, privateKey, pubKey) + Nip04.decrypt(encryptedInfo, privateKey, pubKey) } Nip44v1.EncryptedInfo.V -> { @@ -120,17 +105,24 @@ class Nip44( } } - fun decryptNIP44FromBase64( + private fun decryptNIP44FromBase64( payload: String, privateKey: ByteArray, pubKey: ByteArray, ): String? { if (payload.isEmpty()) return null - val byteArray = Base64.getDecoder().decode(payload) + // 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 + } return when (byteArray[0].toInt()) { - Nip04.EncryptedInfo.V -> nip04.decrypt(payload, privateKey, pubKey) + 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 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 7cf47f42db..1d13573292 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt @@ -21,22 +21,14 @@ package com.vitorpamplona.quartz.nip44Encryption import android.util.Log -import com.goterl.lazysodium.SodiumAndroid -import com.goterl.lazysodium.utils.Key -import com.vitorpamplona.quartz.nip44Encryption.crypto.cryptoStreamXChaCha20Xor -import com.vitorpamplona.quartz.utils.Hex -import com.vitorpamplona.quartz.utils.sha256Hash -import fr.acinq.secp256k1.Secp256k1 -import java.security.SecureRandom +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 java.util.Base64 -class Nip44v1( - val secp256k1: Secp256k1, - val random: SecureRandom, -) { +class Nip44v1 { private val sharedKeyCache = SharedKeyCache() - private val h02 = Hex.decode("02") - private val libSodium = SodiumAndroid() fun clearCache() { sharedKeyCache.clearCache() @@ -55,15 +47,13 @@ class Nip44v1( msg: String, sharedSecret: ByteArray, ): EncryptedInfo { - val nonce = ByteArray(24) - random.nextBytes(nonce) + val nonce = RandomInstance.bytes(24) val cipher = - cryptoStreamXChaCha20Xor( - libSodium = libSodium, + LibSodiumInstance.cryptoStreamXChaCha20Xor( messageBytes = msg.toByteArray(), nonce = nonce, - key = Key.fromBytes(sharedSecret), + key = sharedSecret, ) return EncryptedInfo( @@ -102,12 +92,12 @@ class Nip44v1( encryptedInfo: EncryptedInfo, sharedSecret: ByteArray, ): String? = - cryptoStreamXChaCha20Xor( - libSodium = libSodium, - messageBytes = encryptedInfo.ciphertext, - nonce = encryptedInfo.nonce, - key = Key.fromBytes(sharedSecret), - )?.decodeToString() + LibSodiumInstance + .cryptoStreamXChaCha20Xor( + messageBytes = encryptedInfo.ciphertext, + nonce = encryptedInfo.nonce, + key = sharedSecret, + )?.decodeToString() fun getSharedSecret( privateKey: ByteArray, @@ -126,8 +116,8 @@ class Nip44v1( privateKey: ByteArray, pubKey: ByteArray, ): ByteArray = - sha256Hash( - secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33), + sha256( + Secp256k1Instance.pubKeyTweakMulCompact(pubKey, privateKey), ) class EncryptedInfo( 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 7b3f9d49d5..f4843d403d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -21,30 +21,21 @@ package com.vitorpamplona.quartz.nip44Encryption import android.util.Log -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf -import com.vitorpamplona.quartz.utils.Hex -import fr.acinq.secp256k1.Secp256k1 +import com.vitorpamplona.quartz.utils.LibSodiumInstance +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.Secp256k1Instance import java.nio.ByteBuffer import java.nio.ByteOrder -import java.security.SecureRandom import java.util.Base64 import kotlin.math.floor import kotlin.math.log2 -class Nip44v2( - val secp256k1: Secp256k1, - val random: SecureRandom, -) { +class Nip44v2 { private val sharedKeyCache = SharedKeyCache() - - private val libSodium = SodiumAndroid() - private val lazySodium = LazySodiumAndroid(libSodium) private val hkdf = Hkdf() - private val h02 = Hex.decode("02") private val saltPrefix = "nip44-v2".toByteArray(Charsets.UTF_8) private val hashLength = 32 @@ -65,8 +56,7 @@ class Nip44v2( plaintext: String, conversationKey: ByteArray, ): EncryptedInfo { - val nonce = ByteArray(hashLength) - random.nextBytes(nonce) + val nonce = RandomInstance.bytes(hashLength) return encryptWithNonce(plaintext, conversationKey, nonce) } @@ -78,15 +68,12 @@ class Nip44v2( val messageKeys = getMessageKeys(conversationKey, nonce) val padded = pad(plaintext) - val ciphertext = ByteArray(padded.size) - - lazySodium.cryptoStreamChaCha20IetfXor( - ciphertext, - padded, - padded.size.toLong(), - messageKeys.chachaNonce, - messageKeys.chachaKey, - ) + val ciphertext = + LibSodiumInstance.cryptoStreamChaCha20IetfXor( + padded, + messageKeys.chachaNonce, + messageKeys.chachaKey, + ) val mac = hmacAad(messageKeys.hmacKey, ciphertext, nonce) @@ -128,16 +115,12 @@ class Nip44v2( "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${decoded.mac.toHexKey()}" } - val mLen = decoded.ciphertext.size.toLong() - val padded = ByteArray(decoded.ciphertext.size) - - lazySodium.cryptoStreamChaCha20IetfXor( - padded, - decoded.ciphertext, - mLen, - messageKey.chachaNonce, - messageKey.chachaKey, - ) + val padded = + LibSodiumInstance.cryptoStreamChaCha20IetfXor( + decoded.ciphertext, + messageKey.chachaNonce, + messageKey.chachaKey, + ) return unpad(padded) } @@ -241,7 +224,7 @@ class Nip44v2( privateKey: ByteArray, pubKey: ByteArray, ): ByteArray { - val sharedX = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + val sharedX = Secp256k1Instance.pubKeyTweakMulCompact(pubKey, privateKey) return hkdf.extract(sharedX, saltPrefix) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/crypto/SodiumUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/crypto/SodiumUtils.kt deleted file mode 100644 index 2cc0c902f0..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/crypto/SodiumUtils.kt +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip44Encryption.crypto - -import com.goterl.lazysodium.SodiumAndroid -import com.goterl.lazysodium.utils.Key - -/** - * I initially extended these methods from the Sodium and SodiumAndroid classes But JNI doesn't like - * it. There is some native method overriding bug when using Kotlin extensions - */ -fun cryptoStreamXchacha20XorIc( - libSodium: SodiumAndroid, - cipher: ByteArray, - message: ByteArray, - messageLen: Long, - nonce: ByteArray, - ic: Long, - key: ByteArray, -): Int { - /** - * C++ Code: - * - * unsigned char k2[crypto_core_hchacha20_OUTPUTBYTES]; crypto_core_hchacha20(k2, n, k, NULL); - * return crypto_stream_chacha20_xor_ic( c, m, mlen, n + crypto_core_hchacha20_INPUTBYTES, ic, - * k2); - */ - val k2 = ByteArray(32) - - val nonceChaCha = nonce.drop(16).toByteArray() - assert(nonceChaCha.size == 8) - - libSodium.crypto_core_hchacha20(k2, nonce, key, null) - return libSodium.crypto_stream_chacha20_xor_ic( - cipher, - message, - messageLen, - nonceChaCha, - ic, - k2, - ) -} - -fun cryptoStreamXchacha20Xor( - libSodium: SodiumAndroid, - cipher: ByteArray, - message: ByteArray, - messageLen: Long, - nonce: ByteArray, - key: ByteArray, -): Int = cryptoStreamXchacha20XorIc(libSodium, cipher, message, messageLen, nonce, 0, key) - -fun cryptoStreamXChaCha20Xor( - libSodium: SodiumAndroid, - cipher: ByteArray, - message: ByteArray, - messageLen: Long, - nonce: ByteArray, - key: ByteArray, -): Boolean { - require(!(messageLen < 0 || messageLen > message.size)) { - "messageLen out of bounds: $messageLen" - } - return cryptoStreamXchacha20Xor( - libSodium, - cipher, - message, - messageLen, - nonce, - key, - ) == 0 -} - -fun cryptoStreamXChaCha20Xor( - libSodium: SodiumAndroid, - messageBytes: ByteArray, - nonce: ByteArray, - key: Key, -): ByteArray? { - val mLen = messageBytes.size - val cipher = ByteArray(mLen) - val successful = - cryptoStreamXChaCha20Xor(libSodium, cipher, messageBytes, mLen.toLong(), nonce, key.asBytes) - return if (successful) cipher else null -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt index e2b41c7e60..a65c6f4f17 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip46RemoteSigner -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import java.util.UUID class BunkerRequestConnect( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Decrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Decrypt.kt index 20bf478a65..33923c9c00 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Decrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Decrypt.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip46RemoteSigner -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import java.util.UUID class BunkerRequestNip04Decrypt( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Encrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Encrypt.kt index ee41077309..71d2a6f7e5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Encrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Encrypt.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip46RemoteSigner -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import java.util.UUID class BunkerRequestNip04Encrypt( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Decrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Decrypt.kt index 0769163c53..f4a7e711a5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Decrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Decrypt.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip46RemoteSigner -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import java.util.UUID class BunkerRequestNip44Decrypt( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Encrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Encrypt.kt index 8f7064808a..cff4d36f67 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Encrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Encrypt.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip46RemoteSigner -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import java.util.UUID class BunkerRequestNip44Encrypt( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt index 50a507b111..eff4ce1183 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import com.fasterxml.jackson.core.type.TypeReference import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent.ReadWrite +import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import java.util.UUID class BunkerResponseGetRelays( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePublicKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePublicKey.kt index bbd4a99692..4fc04c5d18 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePublicKey.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePublicKey.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip46RemoteSigner -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import java.util.UUID class BunkerResponsePublicKey( 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 e9d32c51fc..b15b53830b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -94,7 +94,7 @@ class NostrConnectEvent( ) { val tags = arrayOf( - AltTagSerializer.toTagArray(ALT), + AltTag.assemble(ALT), arrayOf("p", 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 97850dd332..0b4676420a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt @@ -22,11 +22,11 @@ package com.vitorpamplona.quartz.nip47WalletConnect import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -86,7 +86,7 @@ class LnZapPaymentRequestEvent( ) { val serializedRequest = EventMapper.mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice)) - val tags = arrayOf(arrayOf("p", walletServicePubkey), AltTagSerializer.toTagArray(ALT)) + val tags = arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT)) signer.nip04Encrypt( serializedRequest, 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 a033e25cc5..87f5a3e888 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.quartz.nip47WalletConnect import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.pointerSizeInBytes 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 815a479f36..a9f52689c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.nip47WalletConnect import android.net.Uri -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import kotlinx.coroutines.CancellationException diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayBuilderExt.kt index 4c7af5e9aa..3c21b34897 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayBuilderExt.kt @@ -20,16 +20,17 @@ */ package com.vitorpamplona.quartz.nip48ProxyTags +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -fun TagArrayBuilder.proxy( +fun TagArrayBuilder.proxy( id: String, pt: ProxyTag.Protocol, ) = add(ProxyTag.assemble(id, pt.code)) -fun TagArrayBuilder.proxy( +fun TagArrayBuilder.proxy( id: String, pt: String, ) = add(ProxyTag.assemble(id, pt)) -fun TagArrayBuilder.proxy(tag: ProxyTag) = add(tag.toTagArray()) +fun TagArrayBuilder.proxy(tag: ProxyTag) = add(tag.toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/ByteArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/ByteArrayExt.kt new file mode 100644 index 0000000000..ae6a339285 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/ByteArrayExt.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip49PrivKeyEnc + +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 + +fun ByteArray.toNCryptSec() = Bech32.encodeBytes(hrp = "ncryptsec", this, Bech32.Encoding.Bech32) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt index e8e7e59df5..b3f0575aad 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt @@ -21,24 +21,15 @@ package com.vitorpamplona.quartz.nip49PrivKeyEnc import android.util.Log -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.toHexKey -import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +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.nip19Bech32.bech32.bechToBytes -import fr.acinq.secp256k1.Secp256k1 -import java.security.SecureRandom +import com.vitorpamplona.quartz.utils.LibSodiumInstance +import com.vitorpamplona.quartz.utils.RandomInstance import java.text.Normalizer -class Nip49( - val secp256k1: Secp256k1, - val random: SecureRandom, -) { - private val libSodium = SodiumAndroid() - private val lazySodium = LazySodiumAndroid(libSodium) - +class Nip49 { fun decrypt( nCryptSec: String, password: String, @@ -56,14 +47,11 @@ class Nip49( val key = SCrypt.scrypt(normalizedPassword, encryptedInfo.salt, n, 8, 1, 32) val m = ByteArray(32) - lazySodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( + LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfDecrypt( m, - longArrayOf(32L), key, encryptedInfo.encryptedKey, - encryptedInfo.encryptedKey.size.toLong(), byteArrayOf(encryptedInfo.keySecurity), - 1, encryptedInfo.nonce, key, ) @@ -87,11 +75,8 @@ class Nip49( ksb: Byte, ): String { check(secretKey.size == 32) { "invalid secret key" } - val salt = ByteArray(16) - random.nextBytes(salt) - - val nonce = ByteArray(24) - random.nextBytes(nonce) + val salt = RandomInstance.bytes(16) + val nonce = RandomInstance.bytes(24) val normalizedPassword = Normalizer.normalize(password, Normalizer.Form.NFKC).toByteArray(Charsets.UTF_8) val n = Math.pow(2.0, logn.toDouble()).toInt() @@ -102,13 +87,10 @@ class Nip49( // byte[] m, long mLen, // byte[] ad, long adLen, // byte[] nSec, byte[] nPub, byte[] k - lazySodium.cryptoAeadXChaCha20Poly1305IetfEncrypt( + LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfEncrypt( ciphertext, - longArrayOf(48), secretKey, - secretKey.size.toLong(), byteArrayOf(ksb), - 1, key, nonce, key, @@ -166,13 +148,8 @@ class Nip49( // ln(n.toDouble()).toInt().toByte(), fun encodePayload(): String = - Bech32.encodeBytes( - hrp = "ncryptsec", - byteArrayOf( - version, - logn, - ) + salt + nonce + keySecurity + encryptedKey, - Bech32.Encoding.Bech32, - ) + ( + byteArrayOf(version, logn) + salt + nonce + keySecurity + encryptedKey + ).toNCryptSec() } } 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 c84f395f3f..d439328699 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt @@ -21,12 +21,13 @@ package com.vitorpamplona.quartz.nip50Search import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +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.ATag -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -50,15 +51,17 @@ class SearchRelayListEvent( companion object { const val KIND = 10007 + 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 = ATag.assembleATagId(KIND, pubKey, FIXED_D_TAG) + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) fun createTagArray(relays: List): Array> = relays .map { arrayOf("relay", it) - }.plusElement(AltTagSerializer.toTagArray("Relay list to use for Search")) + }.plusElement(AltTag.assemble("Relay list to use for Search")) .toTypedArray() fun updateRelayList( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt index 2b14d47854..b4b57b8752 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.quartz.nip51Lists import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +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.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -36,6 +37,8 @@ class BookmarkListEvent( 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" @@ -200,7 +203,7 @@ class BookmarkListEvent( if (tags.any { it.size > 1 && it[0] == "alt" }) { tags } else { - tags + AltTagSerializer.toTagArray(ALT) + tags + AltTag.assemble(ALT) } signer.sign(createdAt, KIND, newTags, content, onReady) @@ -224,7 +227,7 @@ class BookmarkListEvent( events?.forEach { tags.add(arrayOf("e", it)) } users?.forEach { tags.add(arrayOf("p", it)) } addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } - tags.add(AltTagSerializer.toTagArray(ALT)) + 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 index f764731c8b..5f7c0d0c1a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/GeneralListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/GeneralListEvent.kt @@ -21,14 +21,21 @@ package com.vitorpamplona.quartz.nip51Lists import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isTagged +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +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.people.taggedUsers +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet @@ -41,16 +48,22 @@ abstract class GeneralListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun category() = dTag() +) : PrivateTagArrayEvent(id, pubKey, createdAt, kind, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(ETag::parseAsHint) ?: emptyList()) - fun bookmarkedPosts() = taggedEvents() + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(ATag::parseAsHint) ?: emptyList()) - fun bookmarkedPeople() = taggedUsers() + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(PTag::parseAsHint) ?: emptyList()) - fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + @Deprecated("NIP-51 has deprecated Title. Use name instead", ReplaceWith("name()")) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun nameOrTitle() = name() ?: title() fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) @@ -112,28 +125,27 @@ abstract class GeneralListEvent( onReady: (List) -> Unit, ) = privateTags(signer) { onReady(filterEvents(it)) } - fun privateTaggedAddresses( + 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.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + fun filterUsers(tags: Array>): List = tags.mapNotNull(PTag::parseKey) - fun filterHashtags(tags: Array>): List = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } + fun filterHashtags(tags: Array>): List = tags.mapNotNull(HashtagTag::parse) fun filterGeohashes(tags: Array>): List = tags.geohashes() - fun filterEvents(tags: Array>): List = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + fun filterEvents(tags: Array>): List = tags.mapNotNull(ETag::parseId) - fun filterAddresses(tags: Array>): List = - tags - .filter { it.firstOrNull() == "a" } - .mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) + fun filterATags(tags: Array>): List = tags.mapNotNull(ATag::parse) - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } + fun filterAddresses(tags: Array>): List
= tags.mapNotNull(ATag::parseAddress) companion object { fun createPrivateTags( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt index b758d1c589..844026e78e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt @@ -21,14 +21,11 @@ package com.vitorpamplona.quartz.nip51Lists import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent.Companion.FIXED_D_TAG +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +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.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import kotlinx.collections.immutable.ImmutableSet @Immutable class MuteListEvent( @@ -39,46 +36,26 @@ class MuteListEvent( content: String, sig: HexKey, ) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateUserCache: ImmutableSet? = null - - @Transient var publicAndPrivateWordCache: ImmutableSet? = null - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (publicAndPrivateUserCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0) + - pointerSizeInBytes + (publicAndPrivateWordCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0) - override fun dTag() = FIXED_D_TAG fun publicAndPrivateUsersAndWords( signer: NostrSigner, onReady: (PeopleListEvent.UsersAndWords) -> Unit, ) { - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady(PeopleListEvent.UsersAndWords(userList, wordList)) - return - } - } - privateTagsOrEmpty(signer) { - publicAndPrivateUserCache = filterTagList("p", it) - publicAndPrivateWordCache = filterTagList("word", it) - - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady( - PeopleListEvent.UsersAndWords(userList, wordList), - ) - } - } + onReady( + PeopleListEvent.UsersAndWords(filterTagList("p", it), filterTagList("word", it)), + ) } } companion object { const val KIND = 10000 + const val FIXED_D_TAG = "" const val ALT = "Mute List" + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + fun blockListFor(pubKeyHex: HexKey): String = "10000:$pubKeyHex:" fun createListWithTag( @@ -226,65 +203,45 @@ class MuteListEvent( fun removeWord( earlierVersion: MuteListEvent, word: String, - isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit, - ) = removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + ) = removeTag(earlierVersion, "word", word, signer, createdAt, onReady) fun removeUser( earlierVersion: MuteListEvent, pubKeyHex: String, - isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit, - ) = removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + ) = removeTag(earlierVersion, "p", pubKeyHex, signer, createdAt, onReady) fun removeTag( 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 - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + 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, + ) } } } @@ -300,7 +257,7 @@ class MuteListEvent( if (tags.any { it.size > 1 && it[0] == "alt" }) { tags } else { - tags + AltTagSerializer.toTagArray(ALT) + 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 index c1773f5920..6cd6ad7e96 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt @@ -21,13 +21,11 @@ package com.vitorpamplona.quartz.nip51Lists import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +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.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import kotlinx.collections.immutable.ImmutableSet @Immutable class PeopleListEvent( @@ -38,45 +36,6 @@ class PeopleListEvent( content: String, sig: HexKey, ) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateUserCache: ImmutableSet? = null - - @Transient var publicAndPrivateWordCache: ImmutableSet? = null - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (publicAndPrivateUserCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0) + - pointerSizeInBytes + (publicAndPrivateWordCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0) - - fun publicAndPrivateWords( - signer: NostrSigner, - onReady: (ImmutableSet) -> Unit, - ) { - publicAndPrivateWordCache?.let { - onReady(it) - return - } - - privateTagsOrEmpty(signer) { - publicAndPrivateWordCache = filterTagList("word", it) - publicAndPrivateWordCache?.let { onReady(it) } - } - } - - fun publicAndPrivateUsers( - signer: NostrSigner, - onReady: (ImmutableSet) -> Unit, - ) { - publicAndPrivateUserCache?.let { - onReady(it) - return - } - - privateTagsOrEmpty(signer) { - publicAndPrivateUserCache = filterTagList("p", it) - publicAndPrivateUserCache?.let { onReady(it) } - } - } - @Immutable class UsersAndWords( val users: Set = setOf(), @@ -87,24 +46,13 @@ class PeopleListEvent( signer: NostrSigner, onReady: (UsersAndWords) -> Unit, ) { - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady(UsersAndWords(userList, wordList)) - return - } - } - privateTagsOrEmpty(signer) { - publicAndPrivateUserCache = filterTagList("p", it) - publicAndPrivateWordCache = filterTagList("word", it) - - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady( - UsersAndWords(userList, wordList), - ) - } - } + onReady( + UsersAndWords( + filterTagList("p", it), + filterTagList("word", it), + ), + ) } } @@ -127,6 +75,8 @@ class PeopleListEvent( 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( @@ -277,65 +227,45 @@ class PeopleListEvent( fun removeWord( earlierVersion: PeopleListEvent, word: String, - isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit, - ) = removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + ) = removeTag(earlierVersion, "word", word, signer, createdAt, onReady) fun removeUser( earlierVersion: PeopleListEvent, pubKeyHex: String, - isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit, - ) = removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + ) = removeTag(earlierVersion, "p", pubKeyHex, signer, createdAt, onReady) fun removeTag( 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 - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + 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, + ) } } } @@ -351,7 +281,7 @@ class PeopleListEvent( if (tags.any { it.size > 1 && it[0] == "alt" }) { tags } else { - tags + AltTagSerializer.toTagArray(ALT) + 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 6b7633e593..d993c3fcc5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.quartz.nip51Lists import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -50,7 +50,7 @@ class PinListEvent( ) { val tags = mutableListOf>() pins.forEach { tags.add(arrayOf("pin", it)) } - tags.add(AltTagSerializer.toTagArray(ALT)) + tags.add(AltTag.assemble(ALT)) signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) } 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 85285b0a1f..47ce039faa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt @@ -22,11 +22,10 @@ package com.vitorpamplona.quartz.nip51Lists import android.util.Log import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -65,8 +64,8 @@ abstract class PrivateTagArrayEvent( } try { - signer.decrypt(content, pubKey) { - privateTagsCache = EventMapper.mapper.readValue>>(it) + PrivateTagsInContent.decrypt(content, signer) { + privateTagsCache = it privateTagsCache?.let { onReady(it) } } } catch (e: Throwable) { @@ -80,7 +79,7 @@ abstract class PrivateTagArrayEvent( onReady: (content: String) -> Unit, ) { privateTags(signer) { privateTags -> - encryptTags( + PrivateTagsInContent.encryptNip04( privateTags = change(privateTags), signer = signer, ) { encryptedTags -> @@ -99,7 +98,7 @@ abstract class PrivateTagArrayEvent( ) { if (toPrivate) { current.privateTags(signer) { privateTags -> - encryptTags( + PrivateTagsInContent.encryptNip04( privateTags = privateTags.plus(newTag), signer = signer, ) { encryptedTags -> @@ -120,7 +119,7 @@ abstract class PrivateTagArrayEvent( ) { if (toPrivate) { current.privateTags(signer) { privateTags -> - encryptTags( + PrivateTagsInContent.encryptNip04( privateTags = privateTags.plus(newTag), signer = signer, ) { encryptedTags -> @@ -170,7 +169,7 @@ abstract class PrivateTagArrayEvent( onReady: (content: String, tags: Array>) -> Unit, ) { current.privateTags(signer) { privateTags -> - encryptTags( + PrivateTagsInContent.encryptNip04( privateTags = privateTags.replaceAll(oldTagStartsWith, newTag), signer = signer, ) { encryptedTags -> @@ -187,7 +186,7 @@ abstract class PrivateTagArrayEvent( onReady: (content: String, tags: Array>) -> Unit, ) { current.privateTags(signer) { privateTags -> - encryptTags( + PrivateTagsInContent.encryptNip04( privateTags = privateTags.remove(oldTagStartsWith), signer = signer, ) { encryptedTags -> @@ -203,7 +202,7 @@ abstract class PrivateTagArrayEvent( onReady: (content: String, tags: Array>) -> Unit, ) { current.privateTags(signer) { privateTags -> - encryptTags( + PrivateTagsInContent.encryptNip04( privateTags = privateTags.remove(oldTagStartsWith), signer = signer, ) { encryptedTags -> @@ -226,7 +225,7 @@ abstract class PrivateTagArrayEvent( onReady: (content: String, tags: Array>) -> Unit, ) { current.privateTags(signer) { privateTags -> - encryptTags( + PrivateTagsInContent.encryptNip04( privateTags = privateTags.remove(oldTagStartsWith), signer = signer, ) { encryptedTags -> @@ -241,7 +240,7 @@ abstract class PrivateTagArrayEvent( signer: NostrSigner, onReady: (content: String, tags: Array>) -> Unit, ) { - encryptTags( + PrivateTagsInContent.encryptNip04( privateTags = arrayOf(newTag), signer = signer, ) { encryptedTags -> @@ -257,15 +256,5 @@ abstract class PrivateTagArrayEvent( ) { onReady("", arrayOf(arrayOf("d", dTag), newTag)) } - - fun encryptTags( - privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) = signer.nip04Encrypt( - if (privateTags.isNullOrEmpty()) "" else EventMapper.mapper.writeValueAsString(privateTags), - signer.pubKey, - onReady, - ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt index f8da08bbf0..49e9aae885 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.quartz.nip51Lists import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -52,7 +52,7 @@ class RelaySetEvent( ) { val tags = mutableListOf>() relays.forEach { tags.add(arrayOf("r", it)) } - tags.add(AltTagSerializer.toTagArray(ALT)) + 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 06ac817ddd..2ccfca7928 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt @@ -27,9 +27,9 @@ inline fun TagArray.filterToArray(predicate: (Array) -> Boolean): TagArr inline fun TagArray.remove(predicate: (Array) -> Boolean): TagArray = filterNotTo(ArrayList(this.size), predicate).toTypedArray() -inline fun TagArray.remove(startsWith: Array): TagArray = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).toTypedArray() +fun TagArray.remove(startsWith: Array): TagArray = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).toTypedArray() -inline fun TagArray.replaceAll( +fun TagArray.replaceAll( startsWith: Array, newElement: Array, ): TagArray = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).plusElement(newElement).toTypedArray() 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 new file mode 100644 index 0000000000..4e91e5e7a0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/PrivateTagsInContent.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.encryption + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +class PrivateTagsInContent { + companion object { + fun decode(content: String) = EventMapper.mapper.readValue>>(content) + + fun encode(privateTags: Array>) = EventMapper.mapper.writeValueAsString(privateTags) + + fun decrypt( + content: String, + signer: NostrSigner, + onReady: (Array>) -> Unit, + ) { + signer.decrypt(content, signer.pubKey) { + onReady(decode(it)) + } + } + + fun encryptNip04( + privateTags: Array>? = null, + signer: NostrSigner, + onReady: (String) -> Unit, + ) = signer.nip04Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + onReady, + ) + + fun encryptNip44( + privateTags: Array>? = null, + signer: NostrSigner, + onReady: (String) -> Unit, + ) = signer.nip44Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + onReady, + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/NameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/NameTag.kt new file mode 100644 index 0000000000..6935c0782a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/NameTag.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.tags + +class NameTag { + companion object { + const val TAG_NAME = "name" + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].isEmpty()) return null + return tag[1] + } + + @JvmStatic + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} 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 new file mode 100644 index 0000000000..9b46e5ef55 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.tags + +@Deprecated("Use NameTag Instead") +class TitleTag { + companion object { + const val TAG_NAME = "title" + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].isEmpty()) return null + return tag[1] + } + + @JvmStatic + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} 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 e07f9ba264..29a5210d95 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip52Calendar import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -55,7 +55,7 @@ class CalendarDateSlotEvent( createdAt: Long = TimeUtils.now(), onReady: (CalendarDateSlotEvent) -> Unit, ) { - val tags = arrayOf(AltTagSerializer.toTagArray(ALT)) + val tags = arrayOf(AltTag.assemble(ALT)) signer.sign(createdAt, KIND, tags, "", onReady) } } 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 8f9cd7f1e3..8516598156 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.quartz.nip52Calendar import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -45,7 +45,7 @@ class CalendarEvent( createdAt: Long = TimeUtils.now(), onReady: (CalendarEvent) -> Unit, ) { - val tags = arrayOf(AltTagSerializer.toTagArray(ALT)) + val tags = arrayOf(AltTag.assemble(ALT)) signer.sign(createdAt, KIND, tags, "", onReady) } } 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 cd612d77f2..cc0e8f3bcc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip52Calendar import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -57,7 +57,7 @@ class CalendarRSVPEvent( createdAt: Long = TimeUtils.now(), onReady: (CalendarRSVPEvent) -> Unit, ) { - val tags = arrayOf(AltTagSerializer.toTagArray(ALT)) + val tags = arrayOf(AltTag.assemble(ALT)) signer.sign(createdAt, KIND, tags, "", onReady) } } 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 9f716ed77b..db2621c21d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt @@ -21,12 +21,12 @@ package com.vitorpamplona.quartz.nip52Calendar import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.firstTagValueAsLong import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -62,7 +62,7 @@ class CalendarTimeSlotEvent( createdAt: Long = TimeUtils.now(), onReady: (CalendarTimeSlotEvent) -> Unit, ) { - val tags = arrayOf(AltTagSerializer.toTagArray(ALT)) + val tags = arrayOf(AltTag.assemble(ALT)) signer.sign(createdAt, KIND, tags, "", onReady) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesChatMessageEvent.kt deleted file mode 100644 index cc2c5468da..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesChatMessageEvent.kt +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip53LiveActivities - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip19Bech32.parse -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class LiveActivitiesChatMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - private fun innerActivity() = - tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } - ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } - - private fun activityHex() = innerActivity()?.getOrNull(1) - - fun activity() = - innerActivity()?.let { - if (it.size > 1) { - ATag.parse(it[1], it.getOrNull(2)) - } else { - null - } - } - - override fun markedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") - - override fun unMarkedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") - - companion object { - const val KIND = 1311 - const val ALT = "Live activity chat message" - - fun create( - message: String, - activity: ATag, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - isDraft: Boolean, - onReady: (LiveActivitiesChatMessageEvent) -> Unit, - ) { - val content = message - val tags = - mutableListOf( - arrayOf("a", activity.toTag(), "", "root"), - ) - replyTos?.forEach { tags.add(arrayOf("e", it)) } - mentions?.forEach { tags.add(arrayOf("p", it)) } - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) - } - geohash?.let { tags.addAll(geohashMipMap(it)) } - imetas?.forEach { - tags.add(Nip92MediaAttachments.createTag(it)) - } - emojis?.forEach { tags.add(it.toTagArray()) } - tags.add(AltTagSerializer.toTagArray(ALT)) - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), content, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesEvent.kt deleted file mode 100644 index 3469e9ff5c..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesEvent.kt +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip53LiveActivities - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.audio.Participant -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip10Notes.PTag -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class LiveActivitiesEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) - - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - - fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1) - - fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull() - - fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1) - - fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)) - - fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1) - - fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1) - - fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } - - fun hasHost() = tags.any { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) } - - fun host() = tags.firstOrNull { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }?.get(1) - - fun hosts() = tags.filter { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }.map { PTag(it[1], it.getOrNull(2)) } - - fun checkStatus(eventStatus: String?): String? = - if (eventStatus == STATUS_LIVE && createdAt < TimeUtils.eightHoursAgo()) { - STATUS_ENDED - } else { - eventStatus - } - - fun participantsIntersect(keySet: Set): Boolean = keySet.contains(pubKey) || tags.any { it.size > 1 && it[0] == "p" && it[1] in keySet } - - companion object { - const val KIND = 30311 - const val ALT = "Live activity event" - - const val STATUS_LIVE = "live" - const val STATUS_PLANNED = "planned" - const val STATUS_ENDED = "ended" - - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (LiveActivitiesEvent) -> Unit, - ) { - val tags = arrayOf(AltTagSerializer.toTagArray(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt new file mode 100644 index 0000000000..65f82c8c30 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.chat + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class LiveActivitiesChatMessageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + private fun activityHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + fun activity() = tags.firstNotNullOfOrNull(ATag::parse) + + fun activityAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + + override fun markedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") + + override fun unmarkedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") + + companion object { + const val KIND = 1311 + const val ALT = "Live activity chat message" + + fun reply( + post: String, + replyingTo: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + replyingTo.event.activity()?.let { activity(it) } + reply(replyingTo) + initializer() + } + + fun message( + post: String, + activity: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + activity(activity) + initializer() + } + + fun message( + post: String, + activity: ATag, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, post, createdAt) { + activity(activity) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..07302df2c0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/TagArrayBuilderExt.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.chat + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent + +fun TagArrayBuilder.activity(rep: ATag) = addUnique(rep.toATagArray()) + +fun TagArrayBuilder.activity(rep: EventHintBundle) = addUnique(rep.toATag().toATagArray()) + +fun TagArrayBuilder.reply(rep: EventHintBundle) = addUnique(rep.toMarkedETag(MarkedETag.MARKER.REPLY).toTagArray()) + +fun TagArrayBuilder.notify(list: List) = pTags(list) + +fun TagArrayBuilder.notify(pubkey: PTag) = pTag(pubkey) 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 new file mode 100644 index 0000000000..b94e2a0cd8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RelayListTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StartsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class LiveActivitiesEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun streaming() = tags.firstNotNullOfOrNull(StreamingTag::parse) + + fun starts() = tags.firstNotNullOfOrNull(StartsTag::parse) + + fun ends() = tags.firstNotNullOfOrNull(EndsTag::parse) + + fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parse)) + + fun currentParticipants() = tags.firstNotNullOfOrNull(CurrentParticipantsTag::parse) + + fun totalParticipants() = tags.firstNotNullOfOrNull(TotalParticipantsTag::parse) + + fun participants() = tags.mapNotNull(ParticipantTag::parse) + + fun relays() = tags.mapNotNull(RelayListTag::parse) + + fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).map { it.relayUrls }.flatten() + + fun hasHost() = tags.any(ParticipantTag::isHost) + + fun host() = tags.firstNotNullOfOrNull(ParticipantTag::parseHost) + + fun hosts() = tags.mapNotNull(ParticipantTag::parseHost) + + fun checkStatus(eventStatus: String?): String? = + if (eventStatus == StatusTag.STATUS.LIVE.code && createdAt < TimeUtils.eightHoursAgo()) { + StatusTag.STATUS.ENDED.code + } else { + eventStatus + } + + fun participantsIntersect(keySet: Set): Boolean = keySet.contains(pubKey) || tags.any(ParticipantTag::isIn, keySet) + + companion object { + const val KIND = 30311 + const val ALT = "Live activity event" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (LiveActivitiesEvent) -> Unit, + ) { + val tags = arrayOf(AltTag.assemble(ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/CurrentParticipantsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/CurrentParticipantsTag.kt new file mode 100644 index 0000000000..934a4637b5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/CurrentParticipantsTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags + +class CurrentParticipantsTag { + companion object { + const val TAG_NAME = "current_participants" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Int? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toIntOrNull() + } + + @JvmStatic + fun assemble(participantCount: Int) = arrayOf(TAG_NAME, participantCount.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/EndsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/EndsTag.kt new file mode 100644 index 0000000000..0597b1d54f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/EndsTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags + +class EndsTag { + companion object { + const val TAG_NAME = "ends" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Long? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt new file mode 100644 index 0000000000..a77348d0d7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.isNotName +import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +enum class ROLE( + val code: String, +) { + HOST("host"), + SPEAKER("speaker"), +} + +@Immutable +data class ParticipantTag( + override val pubKey: String, + override val relayHint: String?, + val role: String?, + val proof: String?, +) : PubKeyReferenceTag { + companion object { + const val TAG_NAME = "p" + const val TAG_SIZE = 2 + + fun isIn( + tag: Array, + keys: Set, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1] in keys + + @JvmStatic + fun isHost(tag: Tag): Boolean { + if (tag.isNotName(TAG_NAME, TAG_SIZE)) return false + if (tag[1].length != 64) return false + if (tag.getOrNull(3).equals(ROLE.HOST.code)) return true + return false + } + + @JvmStatic + fun parse(tag: Tag): ParticipantTag? { + if (tag.isNotName(TAG_NAME, TAG_SIZE)) return null + if (tag[1].length != 64) return null + return ParticipantTag(tag[1], tag.getOrNull(2), tag.getOrNull(3), tag.getOrNull(4)) + } + + @JvmStatic + fun parseHost(tag: Tag): ParticipantTag? { + if (tag.isNotName(TAG_NAME, TAG_SIZE)) return null + if (tag[1].length != 64) return null + if (!tag.getOrNull(3).equals(ROLE.HOST.code)) return null + return ParticipantTag(tag[1], tag.getOrNull(2), tag.getOrNull(3), tag.getOrNull(4)) + } + + @JvmStatic + fun parseKey(tag: Tag): String? { + if (tag.isNotName(TAG_NAME, TAG_SIZE)) return null + if (tag[1].length != 64) return null + return tag[1] + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: String?, + role: String?, + proof: String?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint, role, proof) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt new file mode 100644 index 0000000000..fda64e4677 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags + +class RelayListTag( + val relayUrls: List, +) { + companion object { + const val TAG_NAME = "relays" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): RelayListTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + val relays = + tag.mapIndexedNotNull { index, s -> + if (index == 0) null else s + } + return RelayListTag(relays) + } + + @JvmStatic + fun assemble(urls: List) = arrayOf(TAG_NAME) + urls.toTypedArray() + + @JvmStatic + fun assemble(tag: RelayListTag) = assemble(tag.relayUrls) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StartsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StartsTag.kt new file mode 100644 index 0000000000..8c6cb44881 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StartsTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags + +class StartsTag { + companion object { + const val TAG_NAME = "starts" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Long? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt new file mode 100644 index 0000000000..d6e651b9e4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags + +class StatusTag { + enum class STATUS( + val code: String, + ) { + LIVE("live"), + PLANNED("planned"), + ENDED("ended"), + ; + + fun toTagArray() = assemble(this) + } + + companion object { + const val TAG_NAME = "status" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(status: STATUS) = arrayOf(TAG_NAME, status.code) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StreamingTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StreamingTag.kt new file mode 100644 index 0000000000..7303e1a44b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StreamingTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags + +class StreamingTag { + companion object { + const val TAG_NAME = "streaming" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/TotalParticipantsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/TotalParticipantsTag.kt new file mode 100644 index 0000000000..52bbeac76e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/TotalParticipantsTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags + +class TotalParticipantsTag { + companion object { + const val TAG_NAME = "total_participants" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Int? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toIntOrNull() + } + + @JvmStatic + fun assemble(participantCount: Int) = arrayOf(TAG_NAME, participantCount.toString()) + } +} 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 5369c11956..abb9192f85 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -21,13 +21,15 @@ package com.vitorpamplona.quartz.nip54Wiki import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +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.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -38,13 +40,15 @@ class WikiNoteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), AddressableEvent { - override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + override fun dTag() = tags.dTag() - override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) - override fun addressTag() = ATag.assembleATagId(kind, pubKey, dTag()) + override fun address() = Address(kind, pubKey, dTag()) + + override fun addressTag() = Address.assemble(kind, pubKey, dTag()) fun topics() = hashtags() @@ -77,7 +81,7 @@ class WikiNoteEvent( replyTos?.forEach { tags.add(arrayOf("e", it)) } mentions?.forEach { tags.add(arrayOf("p", it)) } title?.let { tags.add(arrayOf("title", it)) } - tags.add(AltTagSerializer.toTagArray("Wiki Post: $title")) + tags.add(AltTag.assemble("Wiki Post: $title")) signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt index 35f86d37ca..f375dad427 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt @@ -34,8 +34,8 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent enum class SignerType { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt index b06db3df35..b1e5bf32ec 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt @@ -21,11 +21,10 @@ package com.vitorpamplona.quartz.nip55AndroidSigner import android.util.Log -import com.goterl.lazysodium.BuildConfig import com.vitorpamplona.quartz.EventFactory -import com.vitorpamplona.quartz.nip01Core.EventHasher -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -87,10 +86,6 @@ class NostrSignerExternal( toPublicKey: HexKey, onReady: (String) -> Unit, ) { - if (BuildConfig.DEBUG) { - Log.d("NostrExternalSigner", "Encrypt NIP04 Event: $decryptedContent") - } - launcher.encrypt( decryptedContent, toPublicKey, @@ -104,10 +99,6 @@ class NostrSignerExternal( fromPublicKey: HexKey, onReady: (String) -> Unit, ) { - if (BuildConfig.DEBUG) { - Log.d("NostrExternalSigner", "Decrypt NIP04 Event: $encryptedContent") - } - launcher.decrypt( encryptedContent, fromPublicKey, @@ -121,10 +112,6 @@ class NostrSignerExternal( toPublicKey: HexKey, onReady: (String) -> Unit, ) { - if (BuildConfig.DEBUG) { - Log.d("NostrExternalSigner", "Encrypt NIP44 Event: $decryptedContent") - } - launcher.encrypt( decryptedContent, toPublicKey, @@ -138,10 +125,6 @@ class NostrSignerExternal( fromPublicKey: HexKey, onReady: (String) -> Unit, ) { - if (BuildConfig.DEBUG) { - Log.d("NostrExternalSigner", "Decrypt NIP44 Event: $encryptedContent") - } - launcher.decrypt( encryptedContent, fromPublicKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt new file mode 100644 index 0000000000..d96718340f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner + +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +fun signString( + message: String, + privKey: ByteArray, + nonce: ByteArray = RandomInstance.bytes(32), +): ByteArray = Nip01.sign(sha256(message.toByteArray()), privKey, nonce) 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 6123f73bcf..3e222faab3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip56Reports import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable data class ReportedKey( @@ -100,10 +100,10 @@ class ReportEvent( var tags: Array> = arrayOf(reportPostTag, reportAuthorTag) if (reportedPost is AddressableEvent) { - tags += listOf(arrayOf("a", reportedPost.address().toTag())) + tags += listOf(arrayOf("a", reportedPost.aTag().toTag())) } - tags += listOf(AltTagSerializer.toTagArray("Report for ${type.name}")) + tags += listOf(AltTag.assemble("Report for ${type.name}")) signer.sign(createdAt, KIND, tags, content, onReady) } @@ -118,7 +118,7 @@ class ReportEvent( val content = "" val reportAuthorTag = arrayOf("p", reportedUser, type.name.lowercase()) - val alt = AltTagSerializer.toTagArray("Report for ${type.name}") + val alt = AltTag.assemble("Report for ${type.name}") val tags: Array> = arrayOf(reportAuthorTag, alt) signer.sign(createdAt, KIND, tags, content, onReady) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt index 016580a17f..a8855e4b76 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.quartz.nip57Zaps import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.zapPolls.POLL_OPTION +import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.lightning.LnInvoiceUtil -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable @@ -76,7 +76,7 @@ class LnZapEvent( try { zapRequest ?.tags - ?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION } + ?.firstOrNull { it.size > 1 && it[0] == PollOptionTag.TAG_NAME } ?.get(1) ?.toInt() } catch (e: Exception) { 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 bf95cad914..3b0aceb66a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.nip57Zaps import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils 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 10d3d07d85..b4e6cf173b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -21,16 +21,16 @@ package com.vitorpamplona.quartz.nip57Zaps import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.zapPolls.POLL_OPTION -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair +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.core.mapValues -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -115,13 +115,13 @@ class LnZapRequestEvent( arrayOf("e", originalNote.id), arrayOf("p", toUserPubHex ?: originalNote.pubKey), arrayOf("relays") + relays, - AltTagSerializer.toTagArray(ALT), + AltTag.assemble(ALT), ) if (originalNote is AddressableEvent) { - tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) + tags = tags + listOf(arrayOf("a", originalNote.aTag().toTag())) } if (pollOption != null && pollOption >= 0) { - tags = tags + listOf(arrayOf(POLL_OPTION, pollOption.toString())) + tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString())) } if (zapType == LnZapEvent.ZapType.ANONYMOUS) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt index 9f89384e19..1588feb579 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.quartz.nip57Zaps -import com.vitorpamplona.quartz.CryptoUtils +import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.utils.sha256.sha256 import java.nio.charset.Charset import java.security.SecureRandom import javax.crypto.BadPaddingException @@ -38,7 +39,7 @@ class PrivateZapEncryption { ): ByteArray { val str = privkey + id + createdAt.toString() val strbyte = str.toByteArray(Charset.forName("utf-8")) - return CryptoUtils.sha256(strbyte) + return sha256(strbyte) } fun encryptPrivateZapMessage( @@ -46,7 +47,7 @@ class PrivateZapEncryption { privkey: ByteArray, pubkey: ByteArray, ): String { - val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) + val sharedSecret = Nip04.getSharedSecret(privkey, pubkey) val iv = ByteArray(16) SecureRandom().nextBytes(iv) @@ -70,7 +71,7 @@ class PrivateZapEncryption { privkey: ByteArray, pubkey: ByteArray, ): String { - val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) + val sharedSecret = Nip04.getSharedSecret(privkey, pubkey) if (sharedSecret.size != 16 && sharedSecret.size != 32) { throw IllegalArgumentException("Invalid shared secret size") } 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 9215487f5e..d71c73bc09 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip57Zaps import android.util.Log -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.KeyPair -import com.vitorpamplona.quartz.nip01Core.hexToByteArray +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.toHexKey class PrivateZapRequestBuilder { fun signPrivateZapRequest( @@ -105,7 +105,7 @@ class PrivateZapRequestBuilder { try { if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - val altPubKeyFromPrivate = CryptoUtils.pubkeyCreate(altPrivateKeyToUse).toHexKey() + val altPubKeyFromPrivate = Nip01.pubKeyCreate(altPrivateKeyToUse).toHexKey() if (altPubKeyFromPrivate == event.pubKey) { val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/BaseZapSplitSetup.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/BaseZapSplitSetup.kt index 55a4e1d81d..e81c4094d6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/BaseZapSplitSetup.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/BaseZapSplitSetup.kt @@ -24,4 +24,8 @@ sealed interface BaseZapSplitSetup { val weight: Double fun mainId(): String + + companion object { + const val TAG_NAME = "zap" + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/EventExt.kt index 8aa6bbecee..5c865e3af0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/EventExt.kt @@ -21,9 +21,7 @@ package com.vitorpamplona.quartz.nip57Zaps.splits import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent -import com.vitorpamplona.quartz.nip01Core.core.mapTagged -fun Event.hasZapSplitSetup() = tags.hasTagWithContent("zap") +fun Event.hasZapSplitSetup() = tags.hasZapSplitSetup() -fun Event.zapSplitSetup(): List = tags.mapTagged("zap") { ZapSplitSetupParser.parse(it) } +fun Event.zapSplitSetup(): List = tags.zapSplitSetup() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..6448b919b9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip57Zaps.splits + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.zapSplit(zapSplit: BaseZapSplitSetup) = add(ZapSplitSetupSerializer.toTagArray(zapSplit)) + +fun TagArrayBuilder.zapSplits(zapSplits: List) { + addAll(zapSplits.map { ZapSplitSetupSerializer.toTagArray(it) }) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayExt.kt new file mode 100644 index 0000000000..2775ce1d13 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayExt.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip57Zaps.splits + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent +import com.vitorpamplona.quartz.nip01Core.core.mapTagged + +fun TagArray.hasZapSplitSetup() = this.hasTagWithContent(BaseZapSplitSetup.TAG_NAME) + +fun TagArray.zapSplitSetup(): List = this.mapTagged(BaseZapSplitSetup.TAG_NAME) { ZapSplitSetupParser.parse(it) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt index 1455ebefc4..3988dfb60b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip57Zaps.splits -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey data class ZapSplitSetup( val pubKeyHex: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt index 292de0a4b0..0d2e15abc5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt @@ -24,7 +24,7 @@ class ZapSplitSetupParser { companion object { @JvmStatic fun parse(tags: Array): BaseZapSplitSetup? { - require(tags[0] == "zap") + require(tags[0] == BaseZapSplitSetup.TAG_NAME) val isLnAddress = tags[1].contains("@") || tags[1].startsWith("LNURL", true) val weight = if (isLnAddress) 1.0 else (tags.getOrNull(3)?.toDoubleOrNull() ?: 0.0) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt index b10e15917f..853c01a4f5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt @@ -25,8 +25,8 @@ class ZapSplitSetupSerializer { @JvmStatic fun toTagArray(zapSplit: BaseZapSplitSetup): Array = when (zapSplit) { - is ZapSplitSetupLnAddress -> arrayOf("zap", zapSplit.lnAddress) - is ZapSplitSetup -> arrayOf("zap", zapSplit.pubKeyHex, zapSplit.relay ?: "", zapSplit.weight.toString()) + is ZapSplitSetupLnAddress -> arrayOf(BaseZapSplitSetup.TAG_NAME, zapSplit.lnAddress) + is ZapSplitSetup -> arrayOf(BaseZapSplitSetup.TAG_NAME, zapSplit.pubKeyHex, zapSplit.relay ?: "", zapSplit.weight.toString()) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/EventExt.kt index 12c5de2615..414fa21835 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/EventExt.kt @@ -21,6 +21,5 @@ package com.vitorpamplona.quartz.nip57Zaps.zapraiser import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.firstTagValueAsLong -fun Event.zapraiserAmount() = tags.firstTagValueAsLong("zapraiser") +fun Event.zapraiserAmount() = tags.zapraiserAmount() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d854525c29 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip57Zaps.zapraiser + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.zapraiser(amountInSats: Long) = addUnique(ZapRaiserTag.assemble(amountInSats)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayExt.kt similarity index 85% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserSerializer.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayExt.kt index f41ba3c1e7..3f730960d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayExt.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.quartz.nip57Zaps.zapraiser -class ZapRaiserSerializer { - companion object { - @JvmStatic - fun toTagArray(zapRaiserAmount: Long): Array = arrayOf("zapraiser", zapRaiserAmount.toString()) - } -} +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.firstTagValueAsLong + +fun TagArray.zapraiserAmount() = this.firstTagValueAsLong(ZapRaiserTag.TAG_NAME) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserTag.kt new file mode 100644 index 0000000000..192ade51d2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserTag.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip57Zaps.zapraiser + +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class ZapRaiserTag( + val amountInSats: Long, +) { + fun countMemory(): Long = 1 * pointerSizeInBytes + amountInSats.bytesUsedInMemory() + + fun toTagArray() = assemble(amountInSats) + + companion object { + val TAG_NAME = "zapraiser" + + @JvmStatic + fun parse(tags: Array): ZapRaiserTag? { + require(tags[0] == TAG_NAME) + return tags[1].toLongOrNull()?.let { ZapRaiserTag(it) } + } + + @JvmStatic + fun assemble(amountInSats: Long) = arrayOf(TAG_NAME, amountInSats.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt index 5b44a30f5a..b7074d7ebd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.quartz.nip58Badges import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers @Immutable @@ -37,6 +38,8 @@ class BadgeAwardEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { fun awardees() = taggedUsers() + fun awardeeIds() = taggedUserIds() + fun awardDefinition() = taggedAddresses() companion object { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeDefinitionEvent.kt index bef455d22f..a73e426044 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeDefinitionEvent.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.nip58Badges import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey @Immutable class BadgeDefinitionEvent( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt index a312c559e1..bdf3900419 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.quartz.nip58Badges import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents @@ -45,6 +46,8 @@ class BadgeProfilesEvent( private const val STANDARD_D_TAG = "profile_badges" private const val ALT = "List of accepted badges by the author" + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, STANDARD_D_TAG) + fun createAddressTag(pubKey: HexKey): ATag = ATag(KIND, pubKey, STANDARD_D_TAG, null) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt index 2cf83aa1bc..dcef4c57f0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip59Giftwrap -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey class HostStub( val id: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt index 1c653a447b..facc1065b3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.nip59Giftwrap import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey @Immutable open class WrappedEvent( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/Rumor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt similarity index 91% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/Rumor.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt index 8eec17bd07..ccbec1c13f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/Rumor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt @@ -18,14 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip59Giftwrap +package com.vitorpamplona.quartz.nip59Giftwrap.rumors import com.fasterxml.jackson.annotation.JsonProperty import com.vitorpamplona.quartz.EventFactory -import com.vitorpamplona.quartz.nip01Core.EventHasher -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent class Rumor( val id: HexKey?, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/RumorDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorDeserializer.kt similarity index 97% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/RumorDeserializer.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorDeserializer.kt index 9f6f1b8292..f683e44e44 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/RumorDeserializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorDeserializer.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.nip59Giftwrap +package com.vitorpamplona.quartz.nip59Giftwrap.rumors import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/RumorSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorSerializer.kt similarity index 97% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/RumorSerializer.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorSerializer.kt index 4816f1cc5d..a7fb1d0aa0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/RumorSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorSerializer.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.nip59Giftwrap +package com.vitorpamplona.quartz.nip59Giftwrap.rumors import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/SealedRumorEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt similarity index 93% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/SealedRumorEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index cb0dbf4b19..a1b7c9dd27 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/SealedRumorEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -18,13 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip59Giftwrap +package com.vitorpamplona.quartz.nip59Giftwrap.seals import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.nip59Giftwrap.HostStub +import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/GiftWrapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt similarity index 93% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/GiftWrapEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index a07795a719..321ba29e12 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/GiftWrapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -18,17 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip59Giftwrap +package com.vitorpamplona.quartz.nip59Giftwrap.wraps import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.KeyPair import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip59Giftwrap.HostStub +import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes 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 323ed575a4..928ad17749 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt @@ -21,12 +21,13 @@ package com.vitorpamplona.quartz.nip65RelayList import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +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.ATag -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -85,9 +86,11 @@ class AdvertisedRelayListEvent( const val KIND = 10002 const val ALT = "Relay list to discover the user's content" + 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 = ATag.assembleATagId(KIND, pubKey, FIXED_D_TAG) + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) fun updateRelayList( earlierVersion: AdvertisedRelayListEvent, @@ -125,7 +128,7 @@ class AdvertisedRelayListEvent( fun createTagArray(relays: List): Array> = relays .map(Companion::createRelayTag) - .plusElement(AltTagSerializer.toTagArray(ALT)) + .plusElement(AltTag.assemble(ALT)) .toTypedArray() fun create( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt index 2566987ef2..a87ab59f9b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip65RelayList -import com.vitorpamplona.quartz.nip01Core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey class RelayListRecommendationProcessor { companion object { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt new file mode 100644 index 0000000000..620754dd7b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip68Picture + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip68Picture.tags.UserAnnotationTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash + +/** + * Contains the IMeta tags that are used by Picture events. + */ +fun IMetaTagBuilder.magnet(uri: String) = add(MagnetTag.TAG_NAME, uri) + +fun IMetaTagBuilder.mimeType(mime: String) = add(MimeTypeTag.TAG_NAME, mime) + +fun IMetaTagBuilder.alt(alt: String) = add(AltTag.TAG_NAME, alt) + +fun IMetaTagBuilder.hash(hash: HexKey) = add(HashSha256Tag.TAG_NAME, hash) + +fun IMetaTagBuilder.size(size: Int) = add(SizeTag.TAG_NAME, size.toString()) + +fun IMetaTagBuilder.dims(dims: DimensionTag) = add(DimensionTag.TAG_NAME, dims.toString()) + +fun IMetaTagBuilder.blurhash(blurhash: String) = add(BlurhashTag.TAG_NAME, blurhash) + +fun IMetaTagBuilder.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash) + +fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) + +fun IMetaTagBuilder.sensitiveContent(reason: String) = add(ContentWarningTag.TAG_NAME, reason) + +fun IMetaTagBuilder.image(imageUrl: HexKey) = add(ImageTag.TAG_NAME, imageUrl) + +fun IMetaTagBuilder.thumb(thumbUrl: HexKey) = add(ThumbTag.TAG_NAME, thumbUrl) + +fun IMetaTagBuilder.summary(summary: HexKey) = add(SummaryTag.TAG_NAME, summary) + +fun IMetaTagBuilder.fallback(fallback: HexKey) = add(FallbackTag.TAG_NAME, fallback) + +fun IMetaTagBuilder.service(service: HexKey) = add(ServiceTag.TAG_NAME, service) + +fun IMetaTagBuilder.userAnnotations(tag: UserAnnotationTag) = add(UserAnnotationTag.TAG_NAME, tag.toString()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt new file mode 100644 index 0000000000..30fe0ee2da --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip68Picture + +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip68Picture.tags.UserAnnotationTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash + +/** + * Contains the IMeta tags that are used by Picture events. + */ +fun IMetaTag.magnet() = properties.get(MagnetTag.TAG_NAME) + +fun IMetaTag.mimeType() = properties.get(MimeTypeTag.TAG_NAME) + +fun IMetaTag.alt() = properties.get(AltTag.TAG_NAME) + +fun IMetaTag.hash() = properties.get(HashSha256Tag.TAG_NAME) + +fun IMetaTag.size() = properties.get(SizeTag.TAG_NAME) + +fun IMetaTag.dims() = properties.get(DimensionTag.TAG_NAME) + +fun IMetaTag.blurhash() = properties.get(BlurhashTag.TAG_NAME) + +fun IMetaTag.originalHash() = properties.get(OriginalHashTag.TAG_NAME) + +fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME) + +fun IMetaTag.sensitiveContent() = properties.get(ContentWarningTag.TAG_NAME) + +fun IMetaTag.image() = properties.get(ImageTag.TAG_NAME) + +fun IMetaTag.thumb() = properties.get(ThumbTag.TAG_NAME) + +fun IMetaTag.summary() = properties.get(SummaryTag.TAG_NAME) + +fun IMetaTag.fallback() = properties.get(FallbackTag.TAG_NAME) + +fun IMetaTag.service() = properties.get(ServiceTag.TAG_NAME) + +fun IMetaTag.userAnnotations() = properties.get(UserAnnotationTag.TAG_NAME)?.mapNotNull { UserAnnotationTag.parse(it) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt index 6654fa9b21..d14368859e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt @@ -21,25 +21,28 @@ package com.vitorpamplona.quartz.nip68Picture import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -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.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.buildHashtagTags -import com.vitorpamplona.quartz.nip10Notes.PTag -import com.vitorpamplona.quartz.nip10Notes.content.buildUrlRefs -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip22Comments.RootScope -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments.Companion.IMETA -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -52,29 +55,44 @@ class PictureEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { - fun mimeTypes() = tags.filter { it.size > 1 && it[0] == MIME_TYPE } + // --------------- + // current + // -------------- - fun hashes() = tags.filter { it.size > 1 && it[0] == HASH } + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1) + /** old standard didnt use IMetas **/ + private fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) - private fun url() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.URL }?.get(1) + private fun urls() = tags.mapNotNull(UrlTag::parse) - private fun urls() = tags.filter { it.size > 1 && it[0] == PictureMeta.URL }.map { it[1] } + private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) - private fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.MIME_TYPE }?.get(1) + private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) - private fun hash() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.HASH }?.get(1) + private fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) - private fun size() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.FILE_SIZE }?.get(1) + private fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) - private fun alt() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.ALT }?.get(1) + private fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse) - private fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.DIMENSION }?.get(1)?.let { Dimension.parse(it) } + private fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse) - private fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.BLUR_HASH }?.get(1) + private fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) - private fun hasUrl() = tags.any { it.size > 1 && it[0] == PictureMeta.URL } + private fun hasUrl() = tags.any(UrlTag::isTag) + + private fun isOneOf(mimeTypes: Set) = tags.any(MimeTypeTag::isIn, mimeTypes) + + private fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + private fun images() = tags.mapNotNull(ImageTag::parse) + + private fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) + + private fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse) + + private fun fallbacks() = tags.mapNotNull(FallbackTag::parse) // hack to fix pablo's bug fun rootImage() = @@ -86,285 +104,46 @@ class PictureEvent( alt = alt(), hash = hash(), dimension = dimensions(), - size = size()?.toLongOrNull(), - fallback = emptyList(), + size = size(), + service = service(), + fallback = fallbacks(), annotations = emptyList(), ) } - fun imetaTags() = - tags - .map { tagArray -> - if (tagArray.size > 1 && tagArray[0] == IMETA) { - PictureMeta.parse(tagArray) - } else { - null - } - }.plus(rootImage()) - .filterNotNull() + fun imetaTags() = imetas().map { PictureMeta.parse(it) }.plus(rootImage()).filterNotNull() companion object { const val KIND = 20 const val ALT_DESCRIPTION = "List of pictures" - private const val MIME_TYPE = "m" - private const val HASH = "x" - private const val TITLE = "title" - - fun create( - url: String, - msg: String? = null, - title: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: Long? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - usersMentioned: Set = emptySet(), - addressesMentioned: Set = emptySet(), - eventsMentioned: Set = emptySet(), - geohash: String? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - signer: NostrSigner, + fun build( + image: PictureMeta, + description: String, createdAt: Long = TimeUtils.now(), - onReady: (PictureEvent) -> Unit, - ) { - val image = - PictureMeta( - url, - mimeType, - blurhash, - dimensions, - alt, - hash, - size, - emptyList(), - emptyList(), - ) - - create(listOf(image), msg, title, usersMentioned, addressesMentioned, eventsMentioned, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, signer, createdAt, onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(description, createdAt) { + pictureIMeta(image) + initializer() } - fun create( + fun build( images: List, - msg: String? = null, - title: String? = null, - usersMentioned: Set = emptySet(), - addressesMentioned: Set = emptySet(), - eventsMentioned: Set = emptySet(), - geohash: String? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - signer: NostrSigner, + description: String, createdAt: Long = TimeUtils.now(), - onReady: (PictureEvent) -> Unit, - ) { - val tags = mutableListOf(AltTagSerializer.toTagArray(ALT_DESCRIPTION)) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(description, createdAt) { + pictureIMetas(images) + initializer() + } - images.forEach { - tags.add(it.toIMetaArray()) - } - - title?.let { tags.add(arrayOf("title", it)) } - - images.distinctBy { it.hash }.forEach { - if (it.hash != null) { - tags.add(arrayOf("x", it.hash)) - } - } - - images.distinctBy { it.mimeType }.forEach { - if (it.mimeType != null) { - tags.add(arrayOf("m", it.mimeType)) - } - } - - usersMentioned.forEach { tags.add(it.toPTagArray()) } - addressesMentioned.forEach { tags.add(it.toQTagArray()) } - eventsMentioned.forEach { tags.add(it.toQTagArray()) } - - if (msg != null) { - tags.addAll(buildHashtagTags(findHashtags(msg))) - tags.addAll(buildUrlRefs(findURLs(msg))) - } - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) - } - - geohash?.let { tags.addAll(geohashMipMap(it)) } - - signer.sign(createdAt, KIND, tags.toTypedArray(), msg ?: "", onReady) - } - } -} - -class PictureMeta( - val url: String, - val mimeType: String?, - val blurhash: String?, - val dimension: Dimension?, - val alt: String?, - val hash: String?, - val size: Long?, - val fallback: List, - val annotations: List, -) { - fun toIMetaArray(): Array = - ( - listOfNotNull( - "imeta", - "$URL $url", - mimeType?.let { "$MIME_TYPE $it" }, - alt?.let { "$ALT $it" }, - hash?.let { "$HASH $it" }, - size?.let { "$FILE_SIZE $it" }, - dimension?.let { "$DIMENSION $it" }, - blurhash?.let { "$BLUR_HASH $it" }, - ) + - fallback.map { "$FALLBACK $it" } + - annotations.map { "$ANNOTATIONS $it" } - ).toTypedArray() - - companion object { - const val URL = "url" - const val MIME_TYPE = "m" - const val FILE_SIZE = "size" - const val DIMENSION = "dim" - const val HASH = "x" - const val BLUR_HASH = "blurhash" - const val ALT = "alt" - const val FALLBACK = "fallback" - const val ANNOTATIONS = "annotate-user" - - fun parse(tagArray: Array): PictureMeta? { - var url: String? = null - var mimeType: String? = null - var blurhash: String? = null - var dim: Dimension? = null - var alt: String? = null - var hash: String? = null - var size: Long? = null - val fallback = mutableListOf() - val annotations = mutableListOf() - - if (tagArray.size == 2 && - tagArray[1].contains(URL) && - ( - tagArray[1].contains(BLUR_HASH) || - tagArray[1].contains( - FILE_SIZE, - ) - ) - ) { - // hack to fix pablo's bug - val keys = setOf(URL, MIME_TYPE, BLUR_HASH, DIMENSION, ALT, HASH, FILE_SIZE, FALLBACK, ANNOTATIONS) - var keyNextValue: String? = null - val values = mutableListOf() - - tagArray[1].split(" ").forEach { - if (it in keys) { - if (keyNextValue != null && values.isNotEmpty()) { - when (keyNextValue) { - URL -> url = values.joinToString(" ") - MIME_TYPE -> mimeType = values.joinToString(" ") - BLUR_HASH -> blurhash = values.joinToString(" ") - DIMENSION -> dim = Dimension.parse(values.joinToString(" ")) - ALT -> alt = values.joinToString(" ") - HASH -> hash = values.joinToString(" ") - FILE_SIZE -> size = values.joinToString(" ").toLongOrNull() - FALLBACK -> fallback.add(values.joinToString(" ")) - ANNOTATIONS -> { - UserAnnotation.parse(values.joinToString(" "))?.let { - annotations.add(it) - } - } - } - values.clear() - } - keyNextValue = it - } else { - values.add(it) - } - } - - if (keyNextValue != null && values.isNotEmpty()) { - when (keyNextValue) { - URL -> url = values.joinToString(" ") - MIME_TYPE -> mimeType = values.joinToString(" ") - BLUR_HASH -> blurhash = values.joinToString(" ") - DIMENSION -> dim = Dimension.parse(values.joinToString(" ")) - ALT -> alt = values.joinToString(" ") - HASH -> hash = values.joinToString(" ") - FILE_SIZE -> size = values.joinToString(" ").toLongOrNull() - FALLBACK -> fallback.add(values.joinToString(" ")) - ANNOTATIONS -> { - UserAnnotation.parse(values.joinToString(" "))?.let { - annotations.add(it) - } - } - } - values.clear() - keyNextValue = null - } - } else { - tagArray.forEach { - val parts = it.split(" ", limit = 2) - val key = parts[0] - val value = if (parts.size == 2) parts[1] else "" - - if (value.isNotBlank()) { - when (key) { - URL -> url = value - MIME_TYPE -> mimeType = value - BLUR_HASH -> blurhash = value - DIMENSION -> dim = Dimension.parse(value) - ALT -> alt = value - HASH -> hash = value - FILE_SIZE -> size = value.toLongOrNull() - FALLBACK -> fallback.add(value) - ANNOTATIONS -> { - UserAnnotation.parse(value)?.let { - annotations.add(it) - } - } - } - } - } - } - - return url?.let { - PictureMeta(it, mimeType, blurhash, dim, alt, hash, size, fallback, annotations) - } - } - } -} - -class UserAnnotation( - val pubkey: HexKey, - val x: Int, - val y: Int, -) { - override fun toString() = "$pubkey:$x:$y" - - companion object { - fun parse(value: String): UserAnnotation? { - val ann = value.split(":") - if (ann.size == 3) { - val x = ann[1].toIntOrNull() - val y = ann[2].toIntOrNull() - if (x != null && y != null) { - return UserAnnotation(ann[0], x, y) - } - } - - return null + fun build( + description: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + alt(ClassifiedsEvent.ALT_DESCRIPTION) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt new file mode 100644 index 0000000000..df1ffab630 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip68Picture + +import com.vitorpamplona.quartz.nip68Picture.tags.UserAnnotationTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag + +data class PictureMeta( + val url: String, + val mimeType: String? = null, + val blurhash: String? = null, + val dimension: DimensionTag? = null, + val alt: String? = null, + val hash: String? = null, + val size: Int? = null, + val service: String? = null, + val fallback: List = emptyList(), + val annotations: List = emptyList(), +) { + fun toIMetaArray(): Array = + IMetaTagBuilder(url) + .apply { + mimeType?.let { mimeType(it) } + alt?.let { alt(it) } + hash?.let { hash(it) } + size?.let { size(it) } + dimension?.let { dims(it) } + blurhash?.let { blurhash(it) } + service?.let { service(it) } + fallback.forEach { fallback(it) } + annotations.forEach { userAnnotations(it) } + }.build() + .toTagArray() + + companion object { + fun parse(iMeta: IMetaTag): PictureMeta = + PictureMeta( + iMeta.url, + iMeta.mimeType()?.firstOrNull(), + iMeta.blurhash()?.firstOrNull(), + iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + iMeta.alt()?.firstOrNull(), + iMeta.hash()?.firstOrNull(), + iMeta.size()?.firstOrNull()?.toIntOrNull(), + iMeta.service()?.firstOrNull(), + iMeta.fallback() ?: emptyList(), + iMeta.userAnnotations() ?: emptyList(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..4c7c8cb5e4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip68Picture + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.summary(timestamp: Long) = addUnique(PublishedAtTag.assemble(timestamp)) + +fun TagArrayBuilder.pictureIMeta( + url: String, + mimeType: String? = null, + blurhash: String? = null, + dimension: DimensionTag? = null, + hash: String? = null, + size: Int? = null, + alt: String? = null, +) = pictureIMeta(PictureMeta(url, mimeType, blurhash, dimension, alt, hash, size)) + +fun TagArrayBuilder.pictureIMeta(imeta: PictureMeta): TagArrayBuilder { + add(imeta.toIMetaArray()) + imeta.hash?.let { add(HashSha256Tag.assemble(it)) } + imeta.mimeType?.let { add(MimeTypeTag.assemble(it)) } + return this +} + +fun TagArrayBuilder.pictureIMetas(imageUrls: List): TagArrayBuilder { + imageUrls.forEach { pictureIMeta(it) } + return this +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/UserAnnotationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/UserAnnotationTag.kt new file mode 100644 index 0000000000..704da38162 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/UserAnnotationTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip68Picture.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +class UserAnnotationTag( + val pubkey: HexKey, + val x: Int, + val y: Int, +) { + override fun toString() = "$pubkey:$x:$y" + + companion object { + const val TAG_NAME = "annotate-user" + + @JvmStatic + fun parse(value: String): UserAnnotationTag? { + val ann = value.split(":") + if (ann.size == 3) { + val x = ann[1].toIntOrNull() + val y = ann[2].toIntOrNull() + if (x != null && y != null) { + return UserAnnotationTag(ann[0], x, y) + } + } + + return null + } + + @JvmStatic + fun assemble(tag: UserAnnotationTag) = arrayOf(TAG_NAME, tag.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt new file mode 100644 index 0000000000..c902f1f586 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash + +/** + * Contains the IMeta tags that are used by Video events. + */ +fun IMetaTagBuilder.magnet(uri: String) = add(MagnetTag.TAG_NAME, uri) + +fun IMetaTagBuilder.mimeType(mime: String) = add(MimeTypeTag.TAG_NAME, mime) + +fun IMetaTagBuilder.alt(alt: String) = add(AltTag.TAG_NAME, alt) + +fun IMetaTagBuilder.hash(hash: HexKey) = add(HashSha256Tag.TAG_NAME, hash) + +fun IMetaTagBuilder.size(size: Int) = add(SizeTag.TAG_NAME, size.toString()) + +fun IMetaTagBuilder.dims(dims: DimensionTag) = add(DimensionTag.TAG_NAME, dims.toString()) + +fun IMetaTagBuilder.blurhash(blurhash: String) = add(BlurhashTag.TAG_NAME, blurhash) + +fun IMetaTagBuilder.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash) + +fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) + +fun IMetaTagBuilder.sensitiveContent(reason: String) = add(ContentWarningTag.TAG_NAME, reason) + +fun IMetaTagBuilder.image(imageUrl: HexKey) = add(ImageTag.TAG_NAME, imageUrl) + +fun IMetaTagBuilder.thumb(thumbUrl: HexKey) = add(ThumbTag.TAG_NAME, thumbUrl) + +fun IMetaTagBuilder.summary(summary: HexKey) = add(SummaryTag.TAG_NAME, summary) + +fun IMetaTagBuilder.fallback(fallback: HexKey) = add(FallbackTag.TAG_NAME, fallback) + +fun IMetaTagBuilder.service(service: HexKey) = add(ServiceTag.TAG_NAME, service) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt new file mode 100644 index 0000000000..aa17ec54ac --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video + +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash + +/** + * Contains the IMeta tags that are used by Video events. + */ +fun IMetaTag.magnet() = properties.get(MagnetTag.TAG_NAME) + +fun IMetaTag.mimeType() = properties.get(MimeTypeTag.TAG_NAME) + +fun IMetaTag.alt() = properties.get(AltTag.TAG_NAME) + +fun IMetaTag.hash() = properties.get(HashSha256Tag.TAG_NAME) + +fun IMetaTag.size() = properties.get(SizeTag.TAG_NAME) + +fun IMetaTag.dims() = properties.get(DimensionTag.TAG_NAME) + +fun IMetaTag.blurhash() = properties.get(BlurhashTag.TAG_NAME) + +fun IMetaTag.originalHash() = properties.get(OriginalHashTag.TAG_NAME) + +fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME) + +fun IMetaTag.sensitiveContent() = properties.get(ContentWarningTag.TAG_NAME) + +fun IMetaTag.image() = properties.get(ImageTag.TAG_NAME) + +fun IMetaTag.thumb() = properties.get(ThumbTag.TAG_NAME) + +fun IMetaTag.summary() = properties.get(SummaryTag.TAG_NAME) + +fun IMetaTag.fallback() = properties.get(FallbackTag.TAG_NAME) + +fun IMetaTag.service() = properties.get(ServiceTag.TAG_NAME) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..f64c18ec52 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag +import com.vitorpamplona.quartz.nip71Video.tags.TextTrackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.publishedAt(timestamp: Long) = addUnique(PublishedAtTag.assemble(timestamp)) + +fun TagArrayBuilder.duration(durationInSeconds: Int) = addUnique(DurationTag.assemble(durationInSeconds)) + +fun TagArrayBuilder.videoIMeta( + url: String, + mimeType: String? = null, + blurhash: String? = null, + dimension: DimensionTag? = null, + hash: String? = null, + size: Int? = null, + alt: String? = null, +) = videoIMeta(VideoMeta(url, mimeType, blurhash, dimension, alt, hash, size)) + +fun TagArrayBuilder.videoIMeta(imeta: VideoMeta) = add(imeta.toIMetaArray()) + +fun TagArrayBuilder.videoIMetas(imageUrls: List) = addAll(imageUrls.map { it.toIMetaArray() }) + +fun TagArrayBuilder.textTrack(track: TextTrackTag) = add(track.toTagArray()) + +fun TagArrayBuilder.textTracks(tracks: List) = addAll(tracks.map { it.toTagArray() }) + +fun TagArrayBuilder.segment(seg: SegmentTag) = add(seg.toTagArray()) + +fun TagArrayBuilder.segments(segs: List) = addAll(segs.map { it.toTagArray() }) + +fun TagArrayBuilder.participant(key: PTag) = add(key.toTagArray()) + +fun TagArrayBuilder.participants(keys: List) = addAll(keys.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt index 0aea14adbe..6b7588e5d1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt @@ -21,15 +21,31 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip22Comments.RootScope -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments.Companion.IMETA -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag @Immutable abstract class VideoEvent( @@ -42,35 +58,38 @@ abstract class VideoEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), RootScope { - private fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) + /** old standard didnt use IMetas **/ + private fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) - private fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + private fun urls() = tags.mapNotNull(UrlTag::parse) - private fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) - private fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) - private fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + private fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) - private fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } + private fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) - private fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + private fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse) - private fun image() = tags.filter { it.size > 1 && it[0] == IMAGE }.map { it[1] } + private fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse) - private fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1) + private fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) - fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) + private fun hasUrl() = tags.any(UrlTag::isTag) - fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1) + private fun isOneOf(mimeTypes: Set) = tags.any(MimeTypeTag::isIn, mimeTypes) - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == SUMMARY }?.get(1) + private fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun duration() = tags.firstOrNull { it.size > 1 && it[0] == DURATION }?.get(1) + private fun images() = tags.mapNotNull(ImageTag::parse) - fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + private fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) - fun isOneOf(mimeTypes: Set) = tags.any { it.size > 1 && it[0] == FileHeaderEvent.MIME_TYPE && mimeTypes.contains(it[1]) } + private fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse) + + private fun fallbacks() = tags.mapNotNull(FallbackTag::parse) // hack to fix pablo's bug fun rootVideo() = @@ -82,165 +101,30 @@ abstract class VideoEvent( alt = alt(), hash = hash(), dimension = dimensions(), - size = size()?.toIntOrNull(), - service = null, - fallback = emptyList(), - image = image(), + size = size(), + service = service(), + fallback = fallbacks(), + image = images().map { it.imageUrl }, ) } - fun imetaTags() = - tags - .map { tagArray -> - if (tagArray.size > 1 && tagArray[0] == IMETA) { - VideoMeta.parse(tagArray) - } else { - null - } - }.plus(rootVideo()) - .filterNotNull() + // --------------- + // current + // -------------- - companion object { - private const val URL = "url" - private const val MIME_TYPE = "m" - private const val FILE_SIZE = "size" - private const val DIMENSION = "dim" - private const val HASH = "x" - private const val BLUR_HASH = "blurhash" - private const val ALT = "alt" - private const val TITLE = "title" - private const val SUMMARY = "summary" - private const val DURATION = "duration" - private const val IMAGE = "image" - private const val THUMB = "thumb" + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun create( - kind: Int, - dTag: String, - url: String, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: Int? = null, - duration: Int? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - sensitiveContent: Boolean? = null, - service: String? = null, - altDescription: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (T) -> Unit, - ) { - val video = - VideoMeta( - url, - mimeType, - blurhash, - dimensions, - alt, - hash, - size, - service, - emptyList(), - emptyList(), - ) + fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) - val tags = mutableListOf>() + fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse) - tags.add(arrayOf("d", dTag)) - tags.add(arrayOf(ALT, altDescription)) - if (sensitiveContent == true) { - tags.add(ContentWarningSerializer.toTagArray()) - } - duration?.let { tags.add(arrayOf(DURATION, "duration")) } + fun textTrack() = tags.mapNotNull(ETag::parse) - tags.add(video.toIMetaArray()) + fun segments() = tags.mapNotNull(SegmentTag::parse) - val content = alt ?: "" - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } - } -} - -data class VideoMeta( - val url: String, - val mimeType: String?, - val blurhash: String?, - val dimension: Dimension?, - val alt: String?, - val hash: String?, - val size: Int?, - val service: String?, - val fallback: List, - val image: List, -) { - fun toIMetaArray(): Array = - ( - listOfNotNull( - "imeta", - "$URL $url", - mimeType?.let { "$MIME_TYPE $it" }, - alt?.let { "$ALT $it" }, - hash?.let { "$HASH $it" }, - size?.let { "$FILE_SIZE $it" }, - dimension?.let { "$DIMENSION $it" }, - blurhash?.let { "$BLUR_HASH $it" }, - service?.let { "$SERVICE $it" }, - ) + - fallback.map { "$FALLBACK $it" } + - image.map { "$IMAGE $it" } - - ).toTypedArray() - - companion object { - const val URL = "url" - const val MIME_TYPE = "m" - const val FILE_SIZE = "size" - const val DIMENSION = "dim" - const val HASH = "x" - const val BLUR_HASH = "blurhash" - const val ALT = "alt" - const val FALLBACK = "fallback" - const val IMAGE = "image" - const val SERVICE = "service" - - fun parse(tagArray: Array): VideoMeta? { - var url: String? = null - var mimeType: String? = null - var blurhash: String? = null - var dim: Dimension? = null - var alt: String? = null - var hash: String? = null - var size: Int? = null - var service: String? = null - val fallback = mutableListOf() - val images = mutableListOf() - - tagArray.forEach { - val parts = it.split(" ", limit = 2) - val key = parts[0] - val value = if (parts.size == 2) parts[1] else "" - - if (value.isNotBlank()) { - when (key) { - URL -> url = value - MIME_TYPE -> mimeType = value - BLUR_HASH -> blurhash = value - DIMENSION -> dim = Dimension.parse(value) - ALT -> alt = value - HASH -> hash = value - FILE_SIZE -> size = value.toIntOrNull() - SERVICE -> service = value - FALLBACK -> fallback.add(value) - IMAGE -> images.add(value) - } - } - } - - return url?.let { - VideoMeta(it, mimeType, blurhash, dim, alt, hash, size, service, fallback, images) - } - } - } + fun participants() = tags.mapNotNull(PTag::parse) + + fun hashtags() = tags.hashtags() + + fun imetaTags() = imetas().map { VideoMeta.parse(it) }.plus(rootVideo()).filterNotNull() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt index 3b26329b0c..905deb4439 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt @@ -21,10 +21,12 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip22Comments.RootScope -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID @@ -42,40 +44,37 @@ class VideoHorizontalEvent( const val KIND = 34235 const val ALT_DESCRIPTION = "Horizontal Video" - fun create( - url: String, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: Int? = null, - duration: Int? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - sensitiveContent: Boolean? = null, - service: String? = null, + fun build( + video: VideoMeta, + description: String, dTag: String = UUID.randomUUID().toString(), - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (VideoHorizontalEvent) -> Unit, - ) { - create( - kind = KIND, - dTag = dTag, - url = url, - mimeType = mimeType, - alt = alt, - hash = hash, - size = size, - duration = duration, - dimensions = dimensions, - blurhash = blurhash, - sensitiveContent = sensitiveContent, - service = service, - altDescription = ALT_DESCRIPTION, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(description, dTag, createdAt) { + videoIMeta(video) + initializer() + } + + fun build( + video: List, + description: String, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(description, dTag, createdAt) { + videoIMetas(video) + initializer() + } + + fun build( + description: String, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt(ALT_DESCRIPTION) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt new file mode 100644 index 0000000000..6d44e722ce --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag + +data class VideoMeta( + val url: String, + val mimeType: String? = null, + val blurhash: String? = null, + val dimension: DimensionTag? = null, + val alt: String? = null, + val hash: String? = null, + val size: Int? = null, + val service: String? = null, + val fallback: List = emptyList(), + val image: List = emptyList(), +) { + fun toIMetaArray(): Array = + IMetaTagBuilder(url) + .apply { + mimeType?.let { mimeType(it) } + alt?.let { alt(it) } + hash?.let { hash(it) } + size?.let { size(it) } + dimension?.let { dims(it) } + blurhash?.let { blurhash(it) } + service?.let { service(it) } + fallback.forEach { fallback(it) } + image.forEach { image(it) } + }.build() + .toTagArray() + + companion object { + fun parse(iMeta: IMetaTag): VideoMeta = + VideoMeta( + iMeta.url, + iMeta.mimeType()?.firstOrNull(), + iMeta.blurhash()?.firstOrNull(), + iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + iMeta.alt()?.firstOrNull(), + iMeta.hash()?.firstOrNull(), + iMeta.size()?.firstOrNull()?.toIntOrNull(), + iMeta.service()?.firstOrNull(), + iMeta.fallback() ?: emptyList(), + iMeta.image() ?: emptyList(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt index 62c1771bd2..8b8ccf616a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt @@ -21,10 +21,12 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip22Comments.RootScope -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID @@ -42,40 +44,26 @@ class VideoVerticalEvent( const val KIND = 34236 const val ALT_DESCRIPTION = "Vertical Video" - fun create( - url: String, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: Int? = null, - duration: Int? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - sensitiveContent: Boolean? = null, - service: String? = null, + fun build( + video: VideoMeta, + description: String, dTag: String = UUID.randomUUID().toString(), - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (VideoVerticalEvent) -> Unit, - ) { - create( - kind = KIND, - dTag = dTag, - url = url, - mimeType = mimeType, - alt = alt, - hash = hash, - size = size, - duration = duration, - dimensions = dimensions, - blurhash = blurhash, - sensitiveContent = sensitiveContent, - service = service, - altDescription = ALT_DESCRIPTION, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(description, dTag, createdAt) { + videoIMeta(video) + initializer() + } + + fun build( + description: String, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt(ALT_DESCRIPTION) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoViewEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoViewEvent.kt deleted file mode 100644 index 47ada41eff..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoViewEvent.kt +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip71Video - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class VideoViewEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 34237 - - fun create( - video: ATag, - signer: NostrSigner, - viewStart: Long?, - viewEnd: Long?, - createdAt: Long = TimeUtils.now(), - onReady: (VideoViewEvent) -> Unit, - ) { - val tags = mutableListOf>() - - val aTag = video.toTag() - tags.add(arrayOf("d", aTag)) - tags.add(arrayOf("a", aTag)) - if (viewEnd != null) { - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) - } else { - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) - } - - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) - } - - fun addViewedTime( - event: VideoViewEvent, - viewStart: Long?, - viewEnd: Long?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (VideoViewEvent) -> Unit, - ) { - val tags = event.tags.toMutableList() - if (viewEnd != null) { - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) - } else { - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) - } - - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/DurationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/DurationTag.kt new file mode 100644 index 0000000000..4e4fed010e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/DurationTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.tags + +class DurationTag { + companion object { + const val TAG_NAME = "duration" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Int? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toIntOrNull() + } + + @JvmStatic + fun assemble(durationInSeconds: Int) = arrayOf(TAG_NAME, durationInSeconds.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/SegmentTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/SegmentTag.kt new file mode 100644 index 0000000000..6feb0fea04 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/SegmentTag.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.tags + +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +class SegmentTag( + val start: String, // HH:MM:SS.sss + val end: String, // HH:MM:SS.sss + val title: String, + val thumbnailUrl: String?, +) { + fun toTagArray() = assemble(start, end, title, thumbnailUrl) + + companion object { + const val TAG_NAME = "segment" + const val TAG_SIZE = 4 + + @JvmStatic + fun parse(tag: Array): SegmentTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return SegmentTag(tag[1], tag[2], tag[3], tag.getOrNull(4)) + } + + @JvmStatic + fun assemble( + start: String, // HH:MM:SS.sss + end: String, // HH:MM:SS.sss + title: String, + thumbnailUrl: String?, + ) = arrayOfNotNull(TAG_NAME, start, end, title, thumbnailUrl) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/PTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/TextTrackTag.kt similarity index 55% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/PTag.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/TextTrackTag.kt index 06b4c2f5d1..1f0680f461 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/PTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/TextTrackTag.kt @@ -18,42 +18,38 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip10Notes +package com.vitorpamplona.quartz.nip71Video.tags -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.hexToByteArray -import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile -import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers -@Immutable -data class PTag( - val pubKeyHex: HexKey, +data class TextTrackTag( + val eventId: HexKey, + var relay: String? = null, ) { - var relay: String? = null - - constructor(pubKeyHex: HexKey, relayHint: String?) : this(pubKeyHex) { - this.relay = relayHint?.ifBlank { null } - } - fun countMemory(): Long = - 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) - pubKeyHex.bytesUsedInMemory() + + 2 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) + eventId.bytesUsedInMemory() + (relay?.bytesUsedInMemory() ?: 0) - fun toNProfile(): String = NProfile.create(pubKeyHex, relay?.let { listOf(it) } ?: emptyList()) - - fun toNPub(): String = pubKeyHex.hexToByteArray().toNpub() - - fun toPTagArray() = removeTrailingNullsAndEmptyOthers("p", pubKeyHex, relay) + fun toTagArray() = arrayOfNotNull(TAG_NAME, eventId, relay) companion object { - fun parse(tags: Array): PTag { - require(tags[0] == "p") - return PTag(tags[1], tags.getOrNull(2)) + const val TAG_NAME = "text-track" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): TextTrackTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return TextTrackTag(tag[1], tag.getOrNull(2)) } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: String?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt index 2e8c7a3fd4..6035cae274 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt @@ -21,12 +21,12 @@ package com.vitorpamplona.quartz.nip72ModCommunities import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent.Companion.FIXED_D_TAG +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.parseAtag -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -270,7 +270,7 @@ class CommunityListEvent( if (tags.any { it.size > 1 && it[0] == "alt" }) { tags } else { - tags + AltTagSerializer.toTagArray(ALT) + tags + AltTag.assemble(ALT) } signer.sign(createdAt, KIND, newTags, content, onReady) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityPostApprovalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt similarity index 52% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityPostApprovalEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt index 80e4dc3a49..718b0c5616 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityPostApprovalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt @@ -18,16 +18,21 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip72ModCommunities +package com.vitorpamplona.quartz.nip72ModCommunities.approval import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip19Bech32.parse -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags +import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -50,52 +55,34 @@ class CommunityPostApprovalEvent( null } - fun communities() = - tags - .filter { it.size > 1 && it[0] == "a" } - .mapNotNull { - val aTag = ATag.parse(it[1], it.getOrNull(2)) + fun communities() = taggedATags().filter { it.kind == CommunityDefinitionEvent.KIND } - if (aTag?.kind == CommunityDefinitionEvent.KIND) { - aTag - } else { - null - } - } + fun communityAddresses() = taggedAddresses().filter { it.kind == CommunityDefinitionEvent.KIND } - fun approvedEvents() = - tags - .filter { - it.size > 1 && - ( - it[0] == "e" || - (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.KIND) - ) - }.map { it[1] } + fun approvedEvents() = taggedEvents() + + fun approvedATags() = taggedATags().filter { it.kind != CommunityDefinitionEvent.KIND } + + fun approvedAddresses() = taggedAddresses().filter { it.kind != CommunityDefinitionEvent.KIND } companion object { const val KIND = 4550 - const val ALT = "Community post approval" + const val ALT_DESCRIPTION = "Community post approval" - fun create( - approvedPost: Event, - community: CommunityDefinitionEvent, - signer: NostrSigner, + fun build( + approvedPost: EventHintBundle, + community: EventHintBundle, createdAt: Long = TimeUtils.now(), - onReady: (CommunityPostApprovalEvent) -> Unit, - ) { - val content = approvedPost.toJson() + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) - val communities = arrayOf("a", community.address().toTag()) - val replyToPost = arrayOf("e", approvedPost.id) - val replyToAuthor = arrayOf("p", approvedPost.pubKey) - val innerKind = arrayOf("k", "${approvedPost.kind}") - val alt = AltTagSerializer.toTagArray(ALT) + community(community) + approved(approvedPost) + notifyAuthor(approvedPost) + kind(approvedPost.event.kind) - val tags: Array> = - arrayOf(communities, replyToPost, replyToAuthor, innerKind, alt) - - signer.sign(createdAt, KIND, tags, content, onReady) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d003f195b4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.approval + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +fun TagArrayBuilder.community(event: EventHintBundle) = add(event.toATag().toATagArray()) + +fun TagArrayBuilder.approved(event: EventHintBundle) { + add(event.toETagArray()) + if (event.event is AddressableEvent) { + add(event.toATag().toATagArray()) + } +} + +fun TagArrayBuilder.notifyAuthor(event: EventHintBundle) { + add(event.toETagArray()) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt new file mode 100644 index 0000000000..58c548b2bc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.definition + +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.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.NameTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RulesTag +import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID + +@Immutable +class CommunityDefinitionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun rules() = tags.firstNotNullOfOrNull(RulesTag::parse) + + fun moderators() = tags.mapNotNull(ModeratorTag::parse) + + fun moderatorKeys() = tags.mapNotNull(ModeratorTag::parseKey) + + fun relays() = tags.mapNotNull(RelayTag::parse) + + companion object { + const val KIND = 34550 + const val ALT_DESCRIPTION = "Community definition" + + fun build( + name: String, + description: String, + moderators: List, + image: String? = null, + rules: String? = null, + relays: List? = null, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + + dTag(dTag) + name(name) + description(description) + moderators(moderators) + + relays?.let { relays(it) } + rules?.let { rules(it) } + image?.let { image(image) } + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..e0aa6e2c37 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.definition + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.NameTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RulesTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.image(webUrl: String) = addUnique(ImageTag.assemble(webUrl)) + +fun TagArrayBuilder.rules(rules: String) = addUnique(RulesTag.assemble(rules)) + +fun TagArrayBuilder.moderators(mods: List) = addAll(mods.map { it.toTagArray() }) + +fun TagArrayBuilder.relays(relays: List) = addAll(relays.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/DescriptionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/DescriptionTag.kt new file mode 100644 index 0000000000..84a2edb9d0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/DescriptionTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.definition.tags + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ImageTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ImageTag.kt new file mode 100644 index 0000000000..9bae1d46a7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ImageTag.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.definition.tags + +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +class ImageTag( + val imageUrl: String, + val dimensions: DimensionTag? = null, +) { + companion object { + const val TAG_NAME = "image" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): ImageTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + if (tag[1].isEmpty()) return null + val dims = tag.getOrNull(2)?.let { DimensionTag.parse(it) } + return ImageTag(tag[1], dims) + } + + @JvmStatic + fun assemble( + imageUrl: String, + dimensions: DimensionTag? = null, + ) = arrayOfNotNull(TAG_NAME, imageUrl, dimensions?.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt new file mode 100644 index 0000000000..08d901cc2d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.definition.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.isNotName +import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +@Immutable +data class ModeratorTag( + override val pubKey: String, + override val relayHint: String?, + val role: String?, +) : PubKeyReferenceTag { + fun toTagArray() = assemble(pubKey, relayHint, role) + + companion object { + const val TAG_NAME = "p" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Tag): ModeratorTag? { + if (tag.isNotName(TAG_NAME, TAG_SIZE)) return null + if (tag[1].length != 64) return null + return ModeratorTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + } + + @JvmStatic + fun parseKey(tag: Tag): String? { + if (tag.isNotName(TAG_NAME, TAG_SIZE)) return null + if (tag[1].length != 64) return null + return tag[1] + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: String?, + role: String?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint, role) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/NameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/NameTag.kt new file mode 100644 index 0000000000..0c66da66d4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/NameTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.definition.tags + +class NameTag { + companion object { + const val TAG_NAME = "name" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(name: String) = arrayOf(TAG_NAME, name) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt new file mode 100644 index 0000000000..a70f2f7d98 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.definition.tags + +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +class RelayTag( + val url: String, + val marker: String? = null, +) { + fun toTagArray() = assemble(url, marker) + + companion object { + const val TAG_NAME = "relay" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): RelayTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return RelayTag(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun assemble( + url: String, + marker: String? = null, + ) = arrayOfNotNull(TAG_NAME, url, marker) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RulesTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RulesTag.kt new file mode 100644 index 0000000000..467216f8f4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RulesTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.definition.tags + +class RulesTag { + companion object { + const val TAG_NAME = "rules" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(rules: String) = arrayOf(TAG_NAME, rules) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/BookId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/BookId.kt new file mode 100644 index 0000000000..812f789587 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/BookId.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +class BookId( + val isbn: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(isbn) + + override fun toKind() = toKind(isbn) + + override fun hint() = hint + + companion object { + // "isbn:9780765382030" + fun toScope(isbn: String) = "isbn:" + isbn.lowercase().replace("-", "") + + fun toKind(isbn: String) = "isbn" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt new file mode 100644 index 0000000000..83acbeca67 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +interface ExternalId { + fun toScope(): String + + fun toKind(): String + + fun hint(): String? +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/GeohashId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/GeohashId.kt new file mode 100644 index 0000000000..94c3d1ee5b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/GeohashId.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +class GeohashId( + val geohash: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(geohash) + + override fun toKind() = toKind(geohash) + + override fun hint() = hint + + companion object { + fun toScope(geohash: String) = "geo:" + geohash.lowercase() + + fun toKind(geohash: String) = "geo" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/HashtagId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/HashtagId.kt new file mode 100644 index 0000000000..7ce563364c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/HashtagId.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +class HashtagId( + val topic: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(topic) + + override fun toKind() = toKind(topic) + + override fun hint() = hint + + companion object { + fun toScope(topic: String) = "#" + topic.lowercase() + + fun toKind(topic: String) = "#" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/MovieId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/MovieId.kt new file mode 100644 index 0000000000..be8e316fce --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/MovieId.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +import kotlin.math.min + +class MovieId( + val isan: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(isan) + + override fun toKind() = toKind(isan) + + override fun hint() = hint + + companion object { + // "isan:0000-0000-401A-0000-7" + fun toScope(isan: String) = "isan:" + isan.lowercase().substring(0, min(21, isan.length)) + + fun toKind(isan: String) = "isan" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PaperId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PaperId.kt new file mode 100644 index 0000000000..1a47e57491 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PaperId.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +class PaperId( + val doi: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(doi) + + override fun toKind() = toKind(doi) + + override fun hint() = hint + + companion object { + // "doi:10.1000/182" + fun toScope(doi: String) = "doi:" + doi.lowercase() + + fun toKind(doi: String) = "doi" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastEpisodeId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastEpisodeId.kt new file mode 100644 index 0000000000..7ce70ae29b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastEpisodeId.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +class PodcastEpisodeId( + val guid: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(guid) + + override fun toKind() = toKind(guid) + + override fun hint() = hint + + companion object { + // "isbn:9780765382030" + fun toScope(guid: String) = "podcast:item:guid:" + guid + + fun toKind(guid: String) = "podcast:item:guid" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastFeedId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastFeedId.kt new file mode 100644 index 0000000000..f1fe33442d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastFeedId.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +class PodcastFeedId( + val guid: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(guid) + + override fun toKind() = toKind(guid) + + override fun hint() = hint + + companion object { + // "isbn:9780765382030" + fun toScope(guid: String) = "podcast:guid:" + guid + + fun toKind(guid: String) = "podcast:guid" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastPublisherId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastPublisherId.kt new file mode 100644 index 0000000000..77d7fbbb7b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastPublisherId.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +class PodcastPublisherId( + val guid: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(guid) + + override fun toKind() = toKind(guid) + + override fun hint() = hint + + companion object { + // "isbn:9780765382030" + fun toScope(guid: String) = "podcast:publisher:guid:" + guid + + fun toKind(guid: String) = "podcast:publisher:guid" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/UrlId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/UrlId.kt new file mode 100644 index 0000000000..996256fa87 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/UrlId.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds + +import com.vitorpamplona.quartz.utils.toStringNoFragment +import com.vitorpamplona.quartz.utils.toStringSchemeHost +import org.czeal.rfc3986.URIReference + +class UrlId( + val url: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(url) + + override fun toKind() = toKind(url) + + override fun hint() = hint + + companion object { + fun toScope(url: String) = URIReference.parse(url).normalize().toStringNoFragment() + + fun toKind(url: String) = URIReference.parse(url).normalize().toStringSchemeHost() + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt index 49231c6ff5..e50abb413d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt @@ -21,12 +21,21 @@ package com.vitorpamplona.quartz.nip75ZapGoals import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.reference +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip75ZapGoals.tags.AmountTag +import com.vitorpamplona.quartz.nip75ZapGoals.tags.ClosedAtTag +import com.vitorpamplona.quartz.nip75ZapGoals.tags.RelayListTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -38,47 +47,48 @@ class GoalEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun topics() = hashtags() + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + + fun closedAt() = tags.firstNotNullOfOrNull(ClosedAtTag::parse) + + fun amount() = tags.firstNotNullOfOrNull(AmountTag::parse) + + fun relays() = tags.firstNotNullOfOrNull(RelayListTag::parse) + companion object { const val KIND = 9041 - const val ALT = "Zap Goal" + const val ALT_DESCRIPTION = "Zap Goal" - private const val SUMMARY = "summary" - private const val CLOSED_AT = "closed_at" - private const val IMAGE = "image" - private const val AMOUNT = "amount" - - fun create( + fun build( description: String, amount: Long, - relays: Set, + relays: List, closedAt: Long? = null, image: String? = null, summary: String? = null, websiteUrl: String? = null, - linkedEvent: Event? = null, - signer: NostrSigner, + linkedEvent: EventHintBundle? = null, createdAt: Long = TimeUtils.now(), - onReady: (GoalEvent) -> Unit, - ) { - val tags = - mutableListOf( - arrayOf(AMOUNT, amount.toString()), - arrayOf("relays") + relays, - AltTagSerializer.toTagArray(ALT), - ) - - if (linkedEvent is AddressableEvent) { - tags.add(arrayOf("a", linkedEvent.address().toTag())) - } else if (linkedEvent is Event) { - tags.add(arrayOf("e", linkedEvent.id)) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + amount(amount) + relays(relays) + closedAt?.let { closedAt(it) } + image?.let { image(it) } + summary?.let { summary(it) } + websiteUrl?.let { reference(it) } + linkedEvent?.let { + if (it.event is AddressableEvent) { + linked(it.toATag()) + } + linked(it.toETag()) } - - closedAt?.let { tags.add(arrayOf(CLOSED_AT, it.toString())) } - summary?.let { tags.add(arrayOf(SUMMARY, it)) } - image?.let { tags.add(arrayOf(IMAGE, it)) } - websiteUrl?.let { tags.add(arrayOf("r", it)) } - - signer.sign(createdAt, KIND, tags.toTypedArray(), description, onReady) + alt(ALT_DESCRIPTION) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..8cb28f0d38 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip75ZapGoals + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip75ZapGoals.tags.AmountTag +import com.vitorpamplona.quartz.nip75ZapGoals.tags.ClosedAtTag +import com.vitorpamplona.quartz.nip75ZapGoals.tags.RelayListTag + +fun TagArrayBuilder.amount(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats)) + +fun TagArrayBuilder.relays(urls: List) = add(RelayListTag.assemble(urls)) + +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.closedAt(closedAt: Long) = addUnique(ClosedAtTag.assemble(closedAt)) + +fun TagArrayBuilder.linked(tag: ETag) = addUnique(tag.toTagArray()) + +fun TagArrayBuilder.linked(tag: ATag) = addUnique(tag.toATagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/AmountTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/AmountTag.kt new file mode 100644 index 0000000000..e5c24cae3a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/AmountTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip75ZapGoals.tags + +class AmountTag { + companion object { + const val TAG_NAME = "amount" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Long? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(amountInMillisats: Long) = arrayOf(TAG_NAME, amountInMillisats.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/ClosedAtTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/ClosedAtTag.kt new file mode 100644 index 0000000000..2fbd2a8853 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/ClosedAtTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip75ZapGoals.tags + +class ClosedAtTag { + companion object { + const val TAG_NAME = "closed_at" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Long? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/RelayListTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/RelayListTag.kt new file mode 100644 index 0000000000..bb5c0811ab --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/RelayListTag.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip75ZapGoals.tags + +class RelayListTag( + val relayUrls: List, +) { + companion object { + const val TAG_NAME = "relays" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): RelayListTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + val relays = + tag.mapIndexedNotNull { index, s -> + if (index == 0) null else s + } + return RelayListTag(relays) + } + + @JvmStatic + fun assemble(urls: List) = arrayOf(TAG_NAME) + urls.toTypedArray() + + @JvmStatic + fun assemble(tag: RelayListTag) = assemble(tag.relayUrls) + } +} 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 2b2f40cb92..1f543dd3de 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.quartz.nip78AppData -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +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.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils class AppSpecificDataEvent( @@ -39,6 +40,11 @@ class AppSpecificDataEvent( const val KIND = 30078 const val ALT = "Arbitrary app data" + fun createAddress( + pubKey: HexKey, + dTag: String, + ) = Address(KIND, pubKey, dTag) + fun createTag( pubkey: HexKey, dTag: String, @@ -61,7 +67,7 @@ class AppSpecificDataEvent( val newTags = if (withD.none { it.size > 0 && it[0] == "alt" }) { - withD + AltTagSerializer.toTagArray(ALT) + withD + AltTag.assemble(ALT) } else { withD } 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 84475ac03e..b200a51135 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -21,14 +21,17 @@ package com.vitorpamplona.quartz.nip84Highlights import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.addressables.firstTaggedATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.firstTaggedAddress import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUser -import com.vitorpamplona.quartz.nip10Notes.BaseTextNoteEvent -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -39,16 +42,19 @@ class HighlightEvent( tags: Array>, content: String, sig: HexKey, -) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun inUrl() = tags.firstTagValue("r") +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { + fun inUrl() = tags.firstNotNullOfOrNull(ReferenceTag::parse) fun author() = firstTaggedUser() fun quote() = content - fun context() = tags.firstTagValue("context") + fun context() = tags.firstNotNullOfOrNull(ContextTag::parse) - fun inPost() = firstTaggedAddress() + fun inPost() = firstTaggedATag() + + fun inPostAddress() = firstTaggedAddress() fun inPostVersion() = firstTaggedEvent() @@ -62,7 +68,7 @@ class HighlightEvent( createdAt: Long = TimeUtils.now(), onReady: (HighlightEvent) -> Unit, ) { - signer.sign(createdAt, KIND, arrayOf(AltTagSerializer.toTagArray(ALT)), msg, onReady) + signer.sign(createdAt, KIND, arrayOf(AltTag.assemble(ALT)), msg, onReady) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/ContextTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/ContextTag.kt new file mode 100644 index 0000000000..6e46a32e72 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/ContextTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip84Highlights.tags + +class ContextTag { + companion object { + const val TAG_NAME = "context" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(context: String) = arrayOf(TAG_NAME, context) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/PlatformType.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/PlatformType.kt new file mode 100644 index 0000000000..567018380d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/PlatformType.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers + +enum class PlatformType( + val code: String, +) { + WEB("web"), + IOS("ios"), + ANDROID("android"), +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt similarity index 56% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/AppDefinitionEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt index e27e09ca92..d1836cfe7b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/AppDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt @@ -18,19 +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.nip89AppHandlers +package com.vitorpamplona.quartz.nip89AppHandlers.definition import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip01Core.UserMetadata import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.isTaggedKind +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +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 java.io.ByteArrayInputStream +import java.util.UUID @Immutable class AppDefinitionEvent( @@ -50,14 +55,8 @@ class AppDefinitionEvent( cachedMetadata } else { try { - val newMetadata = - EventMapper.mapper.readValue( - ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), - AppMetadata::class.java, - ) - + val newMetadata = AppMetadata.parse(content) cachedMetadata = newMetadata - newMetadata } catch (e: Exception) { e.printStackTrace() @@ -66,29 +65,40 @@ class AppDefinitionEvent( } } - fun supportedKinds() = - tags - .filter { it.size > 1 && it[0] == "k" } - .mapNotNull { runCatching { it[1].toInt() }.getOrNull() } + fun supportedKinds() = tags.kinds() - fun includeKind(kind: String) = tags.any { it.size > 1 && it[0] == "k" && it[1] == kind } + fun includeKind(kind: Int) = tags.isTaggedKind(kind) - fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1) + fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) companion object { const val KIND = 31990 + const val ALT_DESCRIPTION = "App definition event" - fun create( - details: UserMetadata, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AppDefinitionEvent) -> Unit, + // ["web", "https://..../a/", "nevent"] + class PlatformLink( + val platform: PlatformType, + val uri: String, + val entityType: EntityType?, ) { - val tags = - arrayOf( - AltTagSerializer.toTagArray("App definition event for ${details.name}"), - ) - signer.sign(createdAt, KIND, tags, "", onReady) + fun toPlatformLinkTag() = PlatformLinkTag(platform.code, uri, entityType?.code) + + fun toTagArray() = toPlatformLinkTag().toTagArray() + } + + fun build( + details: AppMetadata, + supportedKinds: Set, + links: List, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, details.toJson(), createdAt) { + dTag(dTag) + alt(ALT_DESCRIPTION) + kinds(supportedKinds) + links(links) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/AppMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt similarity index 91% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/AppMetadata.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt index d57d5825ed..af2362174d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/AppMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt @@ -18,11 +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.nip89AppHandlers +package com.vitorpamplona.quartz.nip89AppHandlers.definition import androidx.compose.runtime.Stable import com.fasterxml.jackson.annotation.JsonProperty -import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists +import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -50,11 +50,6 @@ class AppMetadata { var lud06: String? = null var lud16: String? = null - var twitter: String? = null - - @Transient - var tags: ImmutableListOfLists? = null - fun countMemory(): Long = 20 * pointerSizeInBytes + // 20 fields, 4 bytes for each reference (name?.bytesUsedInMemory() ?: 0L) + @@ -73,9 +68,7 @@ class AppMetadata { (nip05?.bytesUsedInMemory() ?: 0L) + (domain?.bytesUsedInMemory() ?: 0L) + (lud06?.bytesUsedInMemory() ?: 0L) + - (lud16?.bytesUsedInMemory() ?: 0L) + - (twitter?.bytesUsedInMemory() ?: 0L) + - (tags?.lists?.sumOf { it.sumOf { it.bytesUsedInMemory() } } ?: 0L) + (lud16?.bytesUsedInMemory() ?: 0L) fun anyName(): String? = displayName ?: name ?: username @@ -116,4 +109,12 @@ class AppMetadata { if (website?.isBlank() == true) website = null if (domain?.isBlank() == true) domain = null } + + fun toJson() = assemble(this) + + companion object { + fun assemble(data: AppMetadata) = EventMapper.mapper.writeValueAsString(data) + + fun parse(content: String) = EventMapper.mapper.readValue(content, AppMetadata::class.java) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/EntityType.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/EntityType.kt new file mode 100644 index 0000000000..d3ae3cfb84 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/EntityType.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.definition + +enum class EntityType( + val code: String, +) { + NOTE("note"), + NEVENT("nevent"), + NADDR("naddr"), + NPUB("npub"), + NPROFILE("nprofile"), +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d5b2d20db8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.definition + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent.Companion.PlatformLink + +fun TagArrayBuilder.links(links: List) = addAll(links.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt new file mode 100644 index 0000000000..94f5f798d8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.definition.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +class PlatformLinkTag( + val platform: String, + val uri: String, + val entityType: String?, +) { + fun toTagArray() = assemble(platform, uri, entityType) + + companion object { + const val TAG_SIZE = 3 + + @JvmStatic + fun match(tag: Tag): Boolean = + if (tag.size >= TAG_SIZE) { + tag[0] == PlatformType.IOS.code || + tag[0] == PlatformType.WEB.code || + tag[0] == PlatformType.ANDROID.code + } else { + false + } + + @JvmStatic + fun parse(tag: Tag): PlatformLinkTag? { + if (match(tag)) return PlatformLinkTag(tag[1], tag[2], tag.getOrNull(3)) + return null + } + + @JvmStatic + fun assemble( + platform: String, + uri: String, + entityType: String?, + ) = arrayOfNotNull(platform, uri, entityType) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt similarity index 51% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityDefinitionEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt index a208d89c54..0b3db29b59 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt @@ -18,18 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip72ModCommunities +package com.vitorpamplona.quartz.nip89AppHandlers.recommendation import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.audio.Participant -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class CommunityDefinitionEvent( +class AppRecommendationEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -37,26 +41,32 @@ class CommunityDefinitionEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + fun recommendations() = tags.mapNotNull(RecommendationTag::parse) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - - fun rules() = tags.firstOrNull { it.size > 1 && it[0] == "rules" }?.get(1) - - fun moderators() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } + fun recommendationAddresses() = tags.mapNotNull(RecommendationTag::parseAddress) companion object { - const val KIND = 34550 - const val ALT = "Community definition" + const val KIND = 31989 + const val ALT_DESCRIPTION = "App recommendations by the author" - fun create( - signer: NostrSigner, + class AppRecommendationItem( + val appDefinitionEvent: AppDefinitionEvent, + val relayHint: String?, + val platform: PlatformType, + ) + + fun build( + supportedKind: String, + appReferences: List, createdAt: Long = TimeUtils.now(), - onReady: (CommunityDefinitionEvent) -> Unit, - ) { - val tags = mutableListOf>() - tags.add(AltTagSerializer.toTagArray(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(supportedKind) + appReferences.forEach { + recommend(it.appDefinitionEvent.addressTag(), it.relayHint, it.platform.code) + } + alt(ALT_DESCRIPTION) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..7df2ad8b01 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.recommendation + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag + +fun TagArrayBuilder.recommend( + addressId: String, + relay: String?, + platform: String?, +) = add(RecommendationTag.assemble(addressId, relay, platform)) + +fun TagArrayBuilder.recommend( + address: Address, + relay: String?, + platform: String?, +) = add(RecommendationTag.assemble(address, relay, platform)) + +fun TagArrayBuilder.recommend(tag: RecommendationTag) = add(tag.toTagArray()) + +fun TagArrayBuilder.recommend(tags: List) = addAll(tags.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt new file mode 100644 index 0000000000..045510e69c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.match +import com.vitorpamplona.quartz.nip01Core.core.valueIfMatches +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +class RecommendationTag( + val address: Address, + val relay: String? = null, + val platform: String? = null, +) { + fun toTagArray() = assemble(address, relay, platform) + + companion object { + const val TAG_NAME = "a" + const val TAG_SIZE = 2 + + @JvmStatic + fun match(tag: Tag) = tag.match(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun parse(tag: Array): RecommendationTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val address = Address.parse(tag[1]) ?: return null + return RecommendationTag(address, tag.getOrNull(2), tag.getOrNull(3)) + } + + @JvmStatic + fun parseAddress(tag: Array) = tag.valueIfMatches(TAG_NAME, TAG_SIZE) + + @JvmStatic + fun assemble( + addressId: String, + relay: String?, + platform: String?, + ) = arrayOfNotNull(TAG_NAME, addressId, relay, platform) + + @JvmStatic + fun assemble( + address: Address, + relay: String?, + platform: String?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay, platform) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt index da39dc3a20..565f9498ec 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.quartz.nip90Dvms import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.AltTagSerializer +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @Stable @@ -53,7 +53,7 @@ class NIP90ContentDiscoveryRequestEvent( val content = "" val tags = mutableListOf>() tags.add(arrayOf("p", dvmPublicKey)) - tags.add(AltTagSerializer.toTagArray(ALT)) + tags.add(AltTag.assemble(ALT)) tags.add(arrayOf("relays") + relays.toTypedArray()) tags.add(arrayOf("param", "max_results", "200")) tags.add(arrayOf("param", "user", forUser)) 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 790de2176a..9d182402e7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.quartz.nip90Dvms import android.util.Log import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip89AppHandlers.AppRecommendationEvent +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 @@ -84,7 +84,7 @@ class NIP90ContentDiscoveryResponseEvent( ) { val tags = arrayOf( - AltTagSerializer.toTagArray(ALT), + AltTag.assemble(ALT), ) signer.sign(createdAt, KIND, tags, "", onReady) } 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 a018930377..cfda406eab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip90Dvms import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.AltTagSerializer -import com.vitorpamplona.quartz.nip89AppHandlers.AppRecommendationEvent +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -85,7 +85,7 @@ class NIP90StatusEvent( ) { val tags = arrayOf( - AltTagSerializer.toTagArray(ALT), + AltTag.assemble(ALT), ) signer.sign(createdAt, KIND, tags, "", onReady) } 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 7a16cf5221..d45c453409 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip90Dvms import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.AltTagSerializer -import com.vitorpamplona.quartz.nip89AppHandlers.AppRecommendationEvent +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -48,7 +48,7 @@ class NIP90UserDiscoveryRequestEvent( ) { val tags = arrayOf( - AltTagSerializer.toTagArray(ALT), + AltTag.assemble(ALT), ) signer.sign(createdAt, KIND, tags, "", onReady) } 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 1239dd41dc..96a87fb619 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip90Dvms import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey 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.AltTagSerializer -import com.vitorpamplona.quartz.nip89AppHandlers.AppRecommendationEvent +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -48,7 +48,7 @@ class NIP90UserDiscoveryResponseEvent( ) { val tags = arrayOf( - AltTagSerializer.toTagArray(ALT), + AltTag.assemble(ALT), ) signer.sign(createdAt, KIND, tags, "", onReady) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/EventExt.kt new file mode 100644 index 0000000000..7e706cd3f8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/EventExt.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip92IMeta + +import com.vitorpamplona.quartz.nip01Core.core.Event + +fun Event.imetas() = tags.imetas() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt index 1c4ae17fc0..7f8c1c8d1b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt @@ -22,5 +22,51 @@ package com.vitorpamplona.quartz.nip92IMeta class IMetaTag( val url: String, - val properties: Map, -) + val properties: Map>, +) { + fun toTagArray() = + arrayOf(TAG_NAME, "$ANCHOR_PROPERTY $url") + + properties + .mapNotNull { (key, value) -> + if (key != ANCHOR_PROPERTY) { + value.map { "$key $it" } + } else { + null + } + }.flatten() + + companion object { + const val TAG_NAME = "imeta" + const val ANCHOR_PROPERTY = "url" + const val TAG_SIZE = 2 + + fun parse(tag: Array): IMetaTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + + val allTags = parseIMeta(tag) + val url = allTags.get(ANCHOR_PROPERTY)?.firstOrNull() + + return if (url != null) { + IMetaTag(url, allTags.minus(ANCHOR_PROPERTY)) + } else { + null + } + } + + private fun parseIMeta(tags: Array): Map> { + val propertiesByKey = mutableMapOf>() + + tags.forEach { tag -> + val parts = tag.split(" ", limit = 2) + when (parts.size) { + 2 -> propertiesByKey.getOrPut(parts[0], { mutableListOf() }).add(parts[1]) + 1 -> propertiesByKey.getOrPut(parts[0], { mutableListOf() }).add("") + } + } + + return propertiesByKey + } + + fun assemble(iMetaTag: IMetaTag) = iMetaTag.toTagArray() + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt index ac81bfeef3..94bd92c2a2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt @@ -20,51 +20,23 @@ */ package com.vitorpamplona.quartz.nip92IMeta -import com.vitorpamplona.quartz.nip01Core.HexKey -import com.vitorpamplona.quartz.nip36SensitiveContent.CONTENT_WARNING -import com.vitorpamplona.quartz.nip94FileMetadata.Dimension -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent.Companion.ALT -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent.Companion.BLUR_HASH -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent.Companion.DIMENSION -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent.Companion.FILE_SIZE -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent.Companion.HASH -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent.Companion.MAGNET_URI -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent.Companion.MIME_TYPE -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent.Companion.ORIGINAL_HASH -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent.Companion.TORRENT_INFOHASH - class IMetaTagBuilder( val url: String, ) { - val properties = mutableMapOf() + val properties = mutableMapOf>() fun add( key: String, value: String, ): IMetaTagBuilder { - properties.set(key, value) + properties.getOrPut(key) { mutableListOf() }.add(value) return this } - fun magnet(uri: String) = add(MAGNET_URI, uri) - - fun mimeType(mime: String) = add(MIME_TYPE, mime) - - fun alt(alt: String) = add(ALT, alt) - - fun hash(hash: HexKey) = add(HASH, hash) - - fun size(size: Int) = add(FILE_SIZE, size.toString()) - - fun dims(dims: Dimension) = add(DIMENSION, dims.toString()) - - fun blurhash(blurhash: String) = add(BLUR_HASH, blurhash) - - fun originalHash(originalHash: String) = add(ORIGINAL_HASH, originalHash) - - fun torrent(uri: String) = add(TORRENT_INFOHASH, uri) - - fun sensitiveContent(reason: String) = add(CONTENT_WARNING, reason) - fun build() = IMetaTag(url, properties) } + +fun imetaTagBuilder( + url: String, + init: IMetaTagBuilder.() -> Unit, +) = IMetaTagBuilder(url).apply(init).build() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/Nip92MediaAttachments.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/Nip92MediaAttachments.kt deleted file mode 100644 index 8210919515..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/Nip92MediaAttachments.kt +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip92IMeta - -class Nip92MediaAttachments { - companion object { - const val IMETA = "imeta" - - fun createTag(header: IMetaTag): Array = - createTag( - header.url, - header.properties, - ) - - fun createTag( - url: String, - tags: Map, - ): Array = - arrayOf( - IMETA, - "url $url", - ) + - tags.mapNotNull { - if (it.key != "url") { - "${it.key} ${it.value}" - } else { - null - } - } - - fun parse( - url: String, - tags: Array>, - ): Map = - tags - .firstOrNull { - it.size > 1 && it[0] == IMETA && it[1] == "url $url" - }?.let { tagList -> - parseIMeta(tagList) - } ?: emptyMap() - - fun parse(tags: Array>): Map> = - tags.filter { it.size > 1 && it[0] == IMETA }.associate { - val allTags = parseIMeta(it) - (allTags.get("url") ?: "") to allTags - } - - private fun parseIMeta(tags: Array): Map = - tags.associate { tag -> - val parts = tag.split(" ", limit = 2) - when (parts.size) { - 2 -> parts[0] to parts[1] - 1 -> parts[0] to "" - else -> "" to "" - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d7201ae7f9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip92IMeta + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.imeta(tag: IMetaTag) = add(tag.toTagArray()) + +fun TagArrayBuilder.imetas(tags: List) = addAll(tags.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayExt.kt new file mode 100644 index 0000000000..27bfb56436 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayExt.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip92IMeta + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.imetas() = this.mapNotNull(IMetaTag::parse) + +fun TagArray.imetasByUrl() = this.imetas().associateBy { it.url } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt index 7c35038ef6..d7abfef1a6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt @@ -21,11 +21,25 @@ package com.vitorpamplona.quartz.nip94FileMetadata import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -37,102 +51,80 @@ class FileHeaderEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) - fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + fun urls() = tags.mapNotNull(UrlTag::parse) - fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) - fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) - fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) - fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) + fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } + fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse) - fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse) - fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) - fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) - fun isOneOf(mimeTypes: Set) = tags.any { it.size > 1 && it[0] == MIME_TYPE && mimeTypes.contains(it[1]) } + fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse) + + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + + fun fallback() = tags.firstNotNullOfOrNull(FallbackTag::parse) + + fun hasUrl() = tags.any(UrlTag::isTag) + + fun isOneOf(mimeTypes: Set) = tags.any(MimeTypeTag::isIn, mimeTypes) companion object { const val KIND = 1063 const val ALT_DESCRIPTION = "Verifiable file url" - const val URL = "url" - const val ENCRYPTION_KEY = "aes-256-gcm" - const val MIME_TYPE = "m" - const val FILE_SIZE = "size" - const val DIMENSION = "dim" - const val HASH = "x" - const val MAGNET_URI = "magnet" - const val TORRENT_INFOHASH = "i" - const val BLUR_HASH = "blurhash" - const val ORIGINAL_HASH = "ox" - const val ALT = "alt" - - fun buildTags( + fun build( url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - sensitiveContent: Boolean? = null, - ): Array> = - listOfNotNull( - arrayOf(URL, url), - magnetUri?.let { arrayOf(MAGNET_URI, it) }, - mimeType?.let { arrayOf(MIME_TYPE, it) }, - alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: AltTagSerializer.toTagArray(ALT_DESCRIPTION), - hash?.let { arrayOf(HASH, it) }, - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it.toString()) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - sensitiveContent?.let { - if (it) { - ContentWarningSerializer.toTagArray() - } else { - null - } - }, - ).toTypedArray() - - fun create( - url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: Dimension? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, + caption: String?, createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit, - ) { - val tags = buildTags(url, magnetUri, mimeType, alt, hash, size, dimensions, blurhash, originalHash, magnetURI, torrentInfoHash, sensitiveContent) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, caption ?: "", createdAt) { + url(url) + caption?.ifBlank { null }?.let { alt(caption) } ?: alt(ALT_DESCRIPTION) + initializer() + } - val content = alt ?: "" - signer.sign(createdAt, KIND, tags, content, onReady) + fun build( + url: String, + caption: String?, + mimeType: String? = null, + hash: String? = null, + size: Int? = null, + dimension: DimensionTag? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetUri: String? = null, + torrentInfoHash: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, caption ?: "", createdAt) { + url(url) + caption?.ifBlank { null }?.let { alt(caption) } ?: alt(ALT_DESCRIPTION) + + hash?.let { hash(it) } + size?.let { fileSize(it) } + mimeType?.let { mimeType(it) } + dimension?.let { dimension(it) } + blurhash?.let { blurhash(it) } + originalHash?.let { originalHash(it) } + magnetUri?.let { magnet(it) } + torrentInfoHash?.let { torrentInfohash(it) } + + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt new file mode 100644 index 0000000000..6e9d4d3ab3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash + +/** + * Contains the IMeta tags that are used by Picture events. + */ +fun IMetaTagBuilder.magnet(uri: String) = add(MagnetTag.TAG_NAME, uri) + +fun IMetaTagBuilder.mimeType(mime: String) = add(MimeTypeTag.TAG_NAME, mime) + +fun IMetaTagBuilder.alt(alt: String) = add(AltTag.TAG_NAME, alt) + +fun IMetaTagBuilder.hash(hash: HexKey) = add(HashSha256Tag.TAG_NAME, hash) + +fun IMetaTagBuilder.size(size: Int) = add(SizeTag.TAG_NAME, size.toString()) + +fun IMetaTagBuilder.dims(dims: DimensionTag) = add(DimensionTag.TAG_NAME, dims.toString()) + +fun IMetaTagBuilder.blurhash(blurhash: String) = add(BlurhashTag.TAG_NAME, blurhash) + +fun IMetaTagBuilder.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash) + +fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) + +fun IMetaTagBuilder.sensitiveContent(reason: String) = add(ContentWarningTag.TAG_NAME, reason) + +fun IMetaTagBuilder.image(imageUrl: HexKey) = add(ImageTag.TAG_NAME, imageUrl) + +fun IMetaTagBuilder.thumb(thumbUrl: HexKey) = add(ThumbTag.TAG_NAME, thumbUrl) + +fun IMetaTagBuilder.summary(summary: HexKey) = add(SummaryTag.TAG_NAME, summary) + +fun IMetaTagBuilder.fallback(fallback: HexKey) = add(FallbackTag.TAG_NAME, fallback) + +fun IMetaTagBuilder.service(service: HexKey) = add(ServiceTag.TAG_NAME, service) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..98f29ae70f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag + +fun TagArrayBuilder.url(url: String) = add(UrlTag.assemble(url)) + +fun TagArrayBuilder.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType)) + +fun TagArrayBuilder.hash(hash: HexKey) = add(HashSha256Tag.assemble(hash)) + +fun TagArrayBuilder.fileSize(size: Int) = add(SizeTag.assemble(size)) + +fun TagArrayBuilder.dimension(dim: DimensionTag) = add(DimensionTag.assemble(dim)) + +fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) + +fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) + +fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) + +fun TagArrayBuilder.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri)) + +fun TagArrayBuilder.image(imageUrl: HexKey) = add(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.thumb(trumbUrl: HexKey) = add(ThumbTag.assemble(trumbUrl)) + +fun TagArrayBuilder.summary(summary: HexKey) = add(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.fallback(fallbackUrl: HexKey) = add(FallbackTag.assemble(fallbackUrl)) + +fun TagArrayBuilder.service(service: HexKey) = add(ServiceTag.assemble(service)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/BlurhashTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/BlurhashTag.kt new file mode 100644 index 0000000000..329314d843 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/BlurhashTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class BlurhashTag { + companion object { + const val TAG_NAME = "blurhash" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(hash: String) = arrayOf(TAG_NAME, hash) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/Dimension.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt similarity index 74% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/Dimension.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt index 3936ba330a..dcfd011ca8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/Dimension.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip94FileMetadata +package com.vitorpamplona.quartz.nip94FileMetadata.tags -class Dimension( +class DimensionTag( val width: Int, val height: Int, ) { @@ -30,8 +30,20 @@ class Dimension( override fun toString() = "${width}x$height" + fun toTagArray() = assemble(this) + companion object { - fun parse(dim: String): Dimension? { + const val TAG_NAME = "dim" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): DimensionTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return parse(tag[1]) + } + + @JvmStatic + fun parse(dim: String): DimensionTag? { if (dim == "0x0") return null val parts = dim.split("x") @@ -41,10 +53,13 @@ class Dimension( val width = parts[0].toInt() val height = parts[1].toInt() - Dimension(width, height) + DimensionTag(width, height) } catch (e: Exception) { null } } + + @JvmStatic + fun assemble(dim: DimensionTag) = arrayOf(TAG_NAME, dim.toString()) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/FallbackTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/FallbackTag.kt new file mode 100644 index 0000000000..4eca6fd064 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/FallbackTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class FallbackTag { + companion object { + const val TAG_NAME = "fallback" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(imageUrl: String) = arrayOf(TAG_NAME, imageUrl) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/HashSha256Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/HashSha256Tag.kt new file mode 100644 index 0000000000..9880e9162a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/HashSha256Tag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class HashSha256Tag { + companion object { + const val TAG_NAME = "x" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME || tag[1].isEmpty()) return null + return tag[1] + } + + @JvmStatic + fun assemble(hash: String) = arrayOf(TAG_NAME, hash) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ImageTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ImageTag.kt new file mode 100644 index 0000000000..7aa7274a2c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ImageTag.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +class ImageTag( + val imageUrl: String, + val hash: HexKey?, +) { + companion object { + const val TAG_NAME = "image" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): ImageTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return ImageTag(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun assemble( + imageUrl: String, + hash: String? = null, + ) = arrayOfNotNull(TAG_NAME, imageUrl, hash) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MagnetTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MagnetTag.kt new file mode 100644 index 0000000000..9bd916cd74 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MagnetTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class MagnetTag { + companion object { + const val TAG_NAME = "magnet" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MimeTypeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MimeTypeTag.kt new file mode 100644 index 0000000000..416b3f0a1c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MimeTypeTag.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class MimeTypeTag { + companion object { + const val TAG_NAME = "m" + const val TAG_SIZE = 2 + + fun isIn( + tag: Array, + mimeTypes: Set, + ) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME && tag[1] in mimeTypes + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/OriginalHashTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/OriginalHashTag.kt new file mode 100644 index 0000000000..6cc31f9546 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/OriginalHashTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class OriginalHashTag { + companion object { + const val TAG_NAME = "ox" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(hash: String) = arrayOf(TAG_NAME, hash) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ServiceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ServiceTag.kt new file mode 100644 index 0000000000..ca4703c05e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ServiceTag.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class ServiceTag { + companion object { + const val TAG_NAME = "service" + const val TAG_SIZE = 2 + + @JvmStatic + fun isTag(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(serviceType: String) = arrayOf(TAG_NAME, serviceType) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SizeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SizeTag.kt new file mode 100644 index 0000000000..4548ea5f5d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SizeTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class SizeTag { + companion object { + const val TAG_NAME = "size" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): Int? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1].toIntOrNull() + } + + @JvmStatic + fun assemble(sizeInBytes: Int) = arrayOf(TAG_NAME, sizeInBytes.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SummaryTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SummaryTag.kt new file mode 100644 index 0000000000..b854ab7a60 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SummaryTag.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class SummaryTag { + companion object { + const val TAG_NAME = "summary" + const val TAG_SIZE = 2 + + @JvmStatic + fun isTag(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(excerpt: String) = arrayOf(TAG_NAME, excerpt) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbTag.kt new file mode 100644 index 0000000000..e11faa1bef --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbTag.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +class ThumbTag( + val imageUrl: String, + val hash: HexKey?, +) { + companion object { + const val TAG_NAME = "thumb" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): ThumbTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return ThumbTag(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun assemble( + imageUrl: String, + hash: String? = null, + ) = arrayOfNotNull(TAG_NAME, imageUrl, hash) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/TorrentInfoHash.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/TorrentInfoHash.kt new file mode 100644 index 0000000000..93b87b03e8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/TorrentInfoHash.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class TorrentInfoHash { + companion object { + const val TAG_NAME = "i" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/UrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/UrlTag.kt new file mode 100644 index 0000000000..4be128be8c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/UrlTag.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +class UrlTag { + companion object { + const val TAG_NAME = "url" + const val TAG_SIZE = 2 + + @JvmStatic + fun isTag(tag: Array) = tag.size >= TAG_SIZE && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/ResultParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/DeleteResult.kt similarity index 70% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/ResultParser.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/DeleteResult.kt index f456788b07..db1101e2bb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/ResultParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/DeleteResult.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip96FileStorage +package com.vitorpamplona.quartz.nip96FileStorage.actions import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -class ResultParser { - fun parseDeleteResults(body: String): DeleteResult { - val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return mapper.readValue(body, DeleteResult::class.java) - } - - fun parseResults(body: String): Nip96Result { - val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return mapper.readValue(body, Nip96Result::class.java) +data class DeleteResult( + val status: String?, + val message: String?, +) { + companion object { + fun parse(body: String): DeleteResult { + val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, DeleteResult::class.java) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Result.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/UploadResult.kt similarity index 75% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Result.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/UploadResult.kt index 6bdba9dd17..8d53e3c141 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Result.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/UploadResult.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip96FileStorage +package com.vitorpamplona.quartz.nip96FileStorage.actions import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -data class Nip96Result( +data class UploadResult( val status: String? = null, val message: String? = null, @JsonProperty("processing_url") @@ -30,14 +32,16 @@ data class Nip96Result( val percentage: Int? = null, @JsonProperty("nip94_event") val nip94Event: PartialEvent? = null, -) +) { + companion object { + fun parse(body: String): UploadResult { + val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, UploadResult::class.java) + } + } +} class PartialEvent( val tags: Array>? = null, val content: String? = null, ) - -data class DeleteResult( - val status: String?, - val message: String?, -) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/FileServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/FileServersEvent.kt similarity index 53% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/FileServersEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/FileServersEvent.kt index 242a7f1e61..b7350bd49f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/FileServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/FileServersEvent.kt @@ -18,14 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip96FileStorage +package com.vitorpamplona.quartz.nip96FileStorage.config import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip96FileStorage.config.tags.ServerTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -37,65 +40,43 @@ class FileServersEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun servers(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "server") { - it[1] - } else { - null - } - } + fun servers(): List = tags.mapNotNull(ServerTag::parse) companion object { const val KIND = 10096 - const val ALT = "File servers used by the author" + const val ALT_DESCRIPTOR = "File servers used by the 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 = ATag.assembleATagId(KIND, pubKey, FIXED_D_TAG) + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun createTagArray(servers: List): Array> = - servers - .map { - arrayOf("server", it) - }.plusElement(AltTagSerializer.toTagArray(ALT)) - .toTypedArray() - - fun updateRelayList( + fun replaceServers( earlierVersion: FileServersEvent, relays: List, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FileServersEvent) -> Unit, - ) { - val tags = - earlierVersion.tags - .filter { it[0] != "server" } - .plus( - relays.map { - arrayOf("server", it) - }, - ).toTypedArray() - - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + ) = eventTemplate(KIND, earlierVersion.content, createdAt) { + remove(ServerTag.TAG_NAME) + servers(relays) } - fun createFromScratch( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileServersEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun create( + fun build( servers: List, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FileServersEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(servers), "", onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTOR) + servers(servers) + initializer() + } + + fun build( + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTOR) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..7f18c9ed43 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip96FileStorage.config + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip96FileStorage.config.tags.ServerTag + +fun TagArrayBuilder.servers(server: String) = add(ServerTag.assemble(server)) + +fun TagArrayBuilder.servers(servers: List) = addAll(ServerTag.assemble(servers)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/tags/ServerTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/tags/ServerTag.kt new file mode 100644 index 0000000000..4e326bb9d9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/tags/ServerTag.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip96FileStorage.config.tags + +class ServerTag { + companion object { + const val TAG_NAME = "server" + const val TAG_SIZE = 2 + + fun isTag(tag: Array) = tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + + @JvmStatic + fun assemble(urls: List) = urls.map { assemble(it) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/ServerInfo.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfo.kt similarity index 97% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/ServerInfo.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfo.kt index 8cccb5df7b..0902bbdb61 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/ServerInfo.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfo.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.nip96FileStorage +package com.vitorpamplona.quartz.nip96FileStorage.info import com.fasterxml.jackson.annotation.JsonProperty diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/ServerInfoParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.kt similarity index 98% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/ServerInfoParser.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.kt index f88d0122d8..0f0dcb647b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/ServerInfoParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.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.nip96FileStorage +package com.vitorpamplona.quartz.nip96FileStorage.info import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/HTTPAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/HTTPAuthorizationEvent.kt index e2c39264ec..08b5d27572 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/HTTPAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/HTTPAuthorizationEvent.kt @@ -21,12 +21,15 @@ package com.vitorpamplona.quartz.nip98HttpAuth import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip98HttpAuth.tags.MethodTag +import com.vitorpamplona.quartz.nip98HttpAuth.tags.PayloadHashTag +import com.vitorpamplona.quartz.nip98HttpAuth.tags.UrlTag import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.Base64 @Immutable class HTTPAuthorizationEvent( @@ -37,28 +40,30 @@ class HTTPAuthorizationEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun method() = tags.firstNotNullOfOrNull(MethodTag::parse) + + fun payloadHash() = tags.firstNotNullOfOrNull(PayloadHashTag::parse) + + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) + + fun rawToken() = Base64.getEncoder().encodeToString(toJson().toByteArray()) + + fun toAuthToken() = "Nostr ${rawToken()}" + companion object { const val KIND = 27235 - fun create( + fun build( url: String, method: String, file: ByteArray? = null, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (HTTPAuthorizationEvent) -> Unit, - ) { - var hash = "" - file?.let { hash = CryptoUtils.sha256(file).toHexKey() } - - val tags = - listOfNotNull( - arrayOf("u", url), - arrayOf("method", method), - arrayOf("payload", hash), - ) - - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + url(url) + method(method) + file?.let { payload(it) } + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..efadeb21d1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/TagArrayBuilderExt.kt @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip98HttpAuth + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip98HttpAuth.tags.MethodTag +import com.vitorpamplona.quartz.nip98HttpAuth.tags.PayloadHashTag +import com.vitorpamplona.quartz.nip98HttpAuth.tags.UrlTag + +fun TagArrayBuilder.url(url: String) = addUnique(UrlTag.assemble(url)) + +fun TagArrayBuilder.method(method: String) = addUnique(MethodTag.assemble(method)) + +fun TagArrayBuilder.payloadHash(hash: String) = addUnique(PayloadHashTag.assemble(hash)) + +fun TagArrayBuilder.payload(bytes: ByteArray) = addUnique(PayloadHashTag.assemble(bytes)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/MethodTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/MethodTag.kt new file mode 100644 index 0000000000..5459cfdf5f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/MethodTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip98HttpAuth.tags + +class MethodTag { + companion object { + const val TAG_NAME = "method" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/PayloadHashTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/PayloadHashTag.kt new file mode 100644 index 0000000000..f8ae6d5830 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/PayloadHashTag.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip98HttpAuth.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +class PayloadHashTag { + companion object { + const val TAG_NAME = "payload" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): HexKey? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(hash: HexKey) = arrayOf(TAG_NAME, hash) + + @JvmStatic + fun assemble(payload: ByteArray) = assemble(sha256(payload).toHexKey()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/UrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/UrlTag.kt new file mode 100644 index 0000000000..a8f1afd40f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/UrlTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip98HttpAuth.tags + +class UrlTag { + companion object { + const val TAG_NAME = "u" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt index 41240d58c6..2e62394054 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt @@ -21,23 +21,24 @@ package com.vitorpamplona.quartz.nip99Classifieds import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.HexKey import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashMipMap -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.buildHashtagTags -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrl -import com.vitorpamplona.quartz.nip31Alts.AltTagSerializer -import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningSerializer -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupSerializer -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.ZapRaiserSerializer -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.Nip92MediaAttachments +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.LocationTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.StatusTag import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID @Immutable class ClassifiedsEvent( @@ -48,164 +49,57 @@ class ClassifiedsEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun condition() = tags.firstOrNull { it.size > 1 && it[0] == "condition" }?.get(1) + fun condition() = tags.firstNotNullOfOrNull(ConditionTag::parse) - fun images() = tags.filter { it.size > 1 && it[0] == "image" }.map { it[1] } + fun images() = tags.mapNotNull(ImageTag::parse) - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + fun status() = tags.firstNotNullOfOrNull(StatusTag::parse) - fun price() = - tags - .firstOrNull { it.size > 1 && it[0] == "price" } - ?.let { Price(it[1], it.getOrNull(2), it.getOrNull(3)) } + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) + fun price() = tags.firstNotNullOfOrNull(PriceTag::parse) - fun isWellFormed(): Boolean { - var hasImage = false - var hasTitle = false - var hasPrice = false + fun location() = tags.firstNotNullOfOrNull(LocationTag::parse) - tags.forEach { - if (it.size > 1) { - if (it[0] == "image") { - hasImage = true - } else if (it[0] == "title") { - hasTitle = true - } else if (it[0] == "price") { - hasPrice = true - } - } - } + fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) - return hasImage && hasPrice && hasTitle - } + fun categories() = tags.hashtags() - fun publishedAt() = - try { - tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() - } catch (_: Exception) { - null - } - - enum class CONDITION( - val value: String, - ) { - NEW("new"), - USED_LIKE_NEW("like new"), - USED_GOOD("good"), - USED_FAIR("fair"), - } + fun isWellFormed() = tags.containsAllTagNamesWithValues(REQUIRED_FIELDS) companion object { const val KIND = 30402 - private val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") - const val ALT = "Classifieds listing" + const val ALT_DESCRIPTION = "Classifieds listing" - fun create( - dTag: String, - title: String?, - image: String?, - summary: String?, - message: String, - price: Price?, - location: String?, - category: String?, - condition: CONDITION?, - publishedAt: Long? = TimeUtils.now(), - replyTos: List?, - addresses: List?, - mentions: List?, - directMentions: Set, - zapReceiver: List? = null, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - signer: NostrSigner, + val REQUIRED_FIELDS = setOf(TitleTag.TAG_NAME, PriceTag.TAG_NAME, ImageTag.TAG_NAME) + + fun build( + title: String, + price: PriceTag, + description: String, + location: String? = null, + condition: ConditionTag.CONDITION? = null, + images: List? = null, + status: StatusTag.STATUS = StatusTag.STATUS.ACTIVE, + dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), - isDraft: Boolean, - onReady: (ClassifiedsEvent) -> Unit, - ) { - val tags = mutableListOf>() + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + title(title) + price(price) + status(status) - replyTos?.forEach { - if (it in directMentions) { - tags.add(arrayOf("e", it, "", "mention")) - } else { - tags.add(arrayOf("e", it)) - } - } - mentions?.forEach { - if (it in directMentions) { - tags.add(arrayOf("p", it, "", "mention")) - } else { - tags.add(arrayOf("p", it)) - } - } - addresses?.forEach { - val aTag = it.toTag() - if (aTag in directMentions) { - tags.add(arrayOf("a", aTag, "", "mention")) - } else { - tags.add(arrayOf("a", aTag)) - } - } + condition?.let { condition(it) } + location?.let { location(it) } + images?.let { images(images) } - tags.add(arrayOf("d", dTag)) - title?.let { tags.add(arrayOf("title", it)) } - image?.let { tags.add(arrayOf("image", it)) } - summary?.let { tags.add(arrayOf("summary", it)) } - price?.let { - if (it.frequency != null && it.currency != null) { - tags.add(arrayOf("price", it.amount, it.currency, it.frequency)) - } else if (it.currency != null) { - tags.add(arrayOf("price", it.amount, it.currency)) - } else { - tags.add(arrayOf("price", it.amount)) - } - } - category?.let { tags.add(arrayOf("t", it)) } - location?.let { tags.add(arrayOf("location", it)) } - publishedAt?.let { tags.add(arrayOf("publishedAt", it.toString())) } - condition?.let { tags.add(arrayOf("condition", it.value)) } - tags.addAll(buildHashtagTags(findHashtags(message))) - zapReceiver?.forEach { tags.add(ZapSplitSetupSerializer.toTagArray(it)) } - zapRaiserAmount?.let { tags.add(ZapRaiserSerializer.toTagArray(it)) } - - findURLs(message).forEach { - val removedParamsFromUrl = - if (it.contains("?")) { - it.split("?")[0].lowercase() - } else if (it.contains("#")) { - it.split("#")[0].lowercase() - } else { - it - } - - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - tags.add(arrayOf("image", it)) - } - tags.add(arrayOf("r", it)) - } - if (markAsSensitive) { - tags.add(ContentWarningSerializer.toTagArray()) - } - geohash?.let { tags.addAll(geohashMipMap(it)) } - imetas?.forEach { tags.add(Nip92MediaAttachments.createTag(it)) } - emojis?.forEach { tags.add(it.toTagArray()) } - tags.add(AltTagSerializer.toTagArray(ALT)) - - if (isDraft) { - signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), message, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) - } + alt(ALT_DESCRIPTION) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..2da2c86daf --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip99Classifieds + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.LocationTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.StatusTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.location(location: String) = addUnique(LocationTag.assemble(location)) + +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.images(imageUrls: List) = addAll(imageUrls.map { ImageTag.assemble(it) }) + +fun TagArrayBuilder.condition(condition: ConditionTag.CONDITION) = addUnique(condition.toTagArray()) + +fun TagArrayBuilder.status(status: StatusTag.STATUS) = addUnique(status.toTagArray()) + +fun TagArrayBuilder.price(price: PriceTag) = addUnique(price.toTagArray()) + +fun TagArrayBuilder.publishedAt(publishedAt: Long) = addUnique(PublishedAtTag.assemble(publishedAt)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt new file mode 100644 index 0000000000..658a694c64 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip99Classifieds.tags + +class ConditionTag { + enum class CONDITION( + val value: String, + ) { + NEW("new"), + USED_LIKE_NEW("like new"), + USED_GOOD("good"), + USED_FAIR("fair"), + ; + + fun toTagArray() = assemble(this) + } + + companion object { + const val TAG_NAME = "condition" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(condition: CONDITION) = arrayOf(TAG_NAME, condition.value) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/LocationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/LocationTag.kt new file mode 100644 index 0000000000..1d8966ef1f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/LocationTag.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip99Classifieds.tags + +class LocationTag { + companion object { + const val TAG_NAME = "location" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(locationName: String) = arrayOf(TAG_NAME, locationName) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/PriceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/PriceTag.kt new file mode 100644 index 0000000000..48c2e48012 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/PriceTag.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip99Classifieds.tags + +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +data class PriceTag( + val amount: String, + val currency: String?, + val frequency: String?, +) { + fun toTagArray() = assemble(amount, currency, frequency) + + companion object { + const val TAG_NAME = "price" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): PriceTag? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return PriceTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + } + + @JvmStatic + fun assemble( + amount: String, + currency: String?, + frequency: String?, + ) = arrayOfNotNull(TAG_NAME, amount, currency, frequency) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/StatusTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/StatusTag.kt new file mode 100644 index 0000000000..d4365b82f7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/StatusTag.kt @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip99Classifieds.tags + +class StatusTag { + enum class STATUS( + val value: String, + ) { + ACTIVE("active"), + SOLD("sold"), + ; + + fun toTagArray() = assemble(this) + } + + companion object { + const val TAG_NAME = "status" + const val TAG_SIZE = 2 + + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + return tag[1] + } + + @JvmStatic + fun assemble(status: STATUS) = arrayOf(TAG_NAME, status.value) + } +} 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 f8c891f87d..e33916bfd0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt @@ -39,3 +39,13 @@ fun Array.startsWith(startsWith: Array): Boolean { } return true } + +public inline fun Array.lastNotNullOfOrNull(transform: (T) -> R?): R? { + for (index in this.indices.reversed()) { + val result = transform(this[index]) + if (result != null) { + return result + } + } + return null +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Ensure.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Ensure.kt new file mode 100644 index 0000000000..c1ec0bce2e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Ensure.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +inline fun ensure( + condition: Boolean, + exit: () -> Nothing, +) { + contract { + returns() implies condition + } + if (!condition) exit() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt index 7f95752ad1..e1ae0e5002 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt @@ -21,29 +21,33 @@ package com.vitorpamplona.quartz.utils object Hex { - private val lowerCaseHex = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') - private val upperCaseHex = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') + private const val LOWER_CASE_HEX = "0123456789abcdef" + private const val UPPER_CASE_HEX = "0123456789ABCDEF" private val hexToByte: IntArray = IntArray(256) { -1 }.apply { - lowerCaseHex.forEachIndexed { index, char -> this[char.code] = index } - upperCaseHex.forEachIndexed { index, char -> this[char.code] = index } + LOWER_CASE_HEX.forEachIndexed { index, char -> this[char.code] = index } + UPPER_CASE_HEX.forEachIndexed { index, char -> this[char.code] = index } } // Encodes both chars in a single Int variable private val byteToHex = IntArray(256) { - (lowerCaseHex[(it shr 4)].code shl 8) or lowerCaseHex[(it and 0xF)].code + (LOWER_CASE_HEX[(it shr 4)].code shl 8) or LOWER_CASE_HEX[(it and 0xF)].code } @JvmStatic fun isHex(hex: String?): Boolean { - if (hex == null) return false - if (hex.isEmpty()) return false - if (hex.length and 1 != 0) return false // must be even + if (hex.isNullOrEmpty()) return false + if (hex.length and 1 != 0) return false - for (c in hex.indices) { - if (hexToByte[hex[c].code] < 0) return false + try { + for (c in hex.indices) { + if (hexToByte[hex[c].code] < 0) return false + } + } catch (e: IllegalArgumentException) { + // there are p tags with emoji's which makes the hex[c].code > 256 + return false } return true diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hmac512.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hmac512.kt new file mode 100644 index 0000000000..c19f7cc4b6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hmac512.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +fun hmac512( + key: ByteArray, + data: ByteArray, +): ByteArray { + val mac = Mac.getInstance("HmacSHA512") + mac.init(SecretKeySpec(key, "HmacSHA512")) + return mac.doFinal(data) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt new file mode 100644 index 0000000000..cdffe0a241 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.SodiumAndroid + +object LibSodiumInstance { + private val libSodium = SodiumAndroid() + private val lazySodium = LazySodiumAndroid(libSodium) + + fun cryptoAeadXChaCha20Poly1305IetfDecrypt( + message: ByteArray, + nSec: ByteArray, + ciphertext: ByteArray, + ad: ByteArray, + nPub: ByteArray, + k: ByteArray, + ): Boolean = + lazySodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( + message, + longArrayOf(message.size.toLong()), + nSec, + ciphertext, + ciphertext.size.toLong(), + ad, + ad.size.toLong(), + nPub, + k, + ) + + fun cryptoAeadXChaCha20Poly1305IetfEncrypt( + ciphertext: ByteArray, + message: ByteArray, + ad: ByteArray, + nSec: ByteArray, + nPub: ByteArray, + k: ByteArray, + ): Boolean = + lazySodium.cryptoAeadXChaCha20Poly1305IetfEncrypt( + ciphertext, + longArrayOf(ciphertext.size.toLong()), + message, + message.size.toLong(), + ad, + ad.size.toLong(), + nSec, + nPub, + k, + ) + + fun cryptoStreamChaCha20IetfXor( + message: ByteArray, + nonce: ByteArray?, + key: ByteArray?, + ): ByteArray { + val ciphertext = ByteArray(message.size) + lazySodium.cryptoStreamChaCha20IetfXor(ciphertext, message, message.size.toLong(), nonce, key) + return ciphertext + } + + // This function wasn't available in the bindings library. I had to move them here from C + fun cryptoStreamXChaCha20Xor( + messageBytes: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray? { + val cipher = ByteArray(messageBytes.size) + val k2 = ByteArray(32) + + val nonceChaCha = nonce.drop(16).toByteArray() + assert(nonceChaCha.size == 8) + + libSodium.crypto_core_hchacha20(k2, nonce, key, null) + val resultCode = + libSodium.crypto_stream_chacha20_xor_ic( + cipher, + messageBytes, + messageBytes.size.toLong(), + nonceChaCha, + 0, + k2, + ) + + return if (resultCode == 0) cipher else null + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt new file mode 100644 index 0000000000..01b03b8217 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import java.security.SecureRandom + +object RandomInstance { + private val randomizer = SecureRandom() + + fun int(bound: Int = Int.MAX_VALUE) = randomizer.nextInt(bound) + + fun bytes(size: Int) = ByteArray(size).also { randomizer.nextBytes(it) } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/Nip01.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt similarity index 61% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/Nip01.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt index 3e2f0cb019..814514cb4c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/Nip01.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt @@ -18,47 +18,42 @@ * AN ACTION OF CONTRACT, TORT OR 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 +package com.vitorpamplona.quartz.utils -import com.vitorpamplona.quartz.CryptoUtils -import com.vitorpamplona.quartz.utils.nextBytes -import com.vitorpamplona.quartz.utils.sha256Hash import fr.acinq.secp256k1.Secp256k1 -import java.security.SecureRandom -class Nip01( - val secp256k1: Secp256k1, - val random: SecureRandom, -) { - /** Provides a 32B "private key" aka random number */ - fun privkeyCreate() = random.nextBytes(32) +object Secp256k1Instance { + private val h02 = Hex.decode("02") + private val secp256k1 = Secp256k1.get() - fun compressedPubkeyCreate(privKey: ByteArray) = secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)) + fun compressedPubKeyFor(privKey: ByteArray) = secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)) - fun pubkeyCreate(privKey: ByteArray) = compressedPubkeyCreate(privKey).copyOfRange(1, 33) + fun isPrivateKeyValid(il: ByteArray): Boolean = secp256k1.secKeyVerify(il) - fun sign( + fun signSchnorr( data: ByteArray, privKey: ByteArray, - nonce: ByteArray? = random.nextBytes(32), + nonce: ByteArray? = RandomInstance.bytes(32), ): ByteArray = secp256k1.signSchnorr(data, privKey, nonce) - fun signDeterministic( + fun signSchnorr( data: ByteArray, privKey: ByteArray, ): ByteArray = secp256k1.signSchnorr(data, privKey, null) - fun verify( + fun verifySchnorr( signature: ByteArray, hash: ByteArray, pubKey: ByteArray, ): Boolean = secp256k1.verifySchnorr(signature, hash, pubKey) - fun sha256(data: ByteArray) = sha256Hash(data) + fun privateKeyAdd( + first: ByteArray, + second: ByteArray, + ): ByteArray = secp256k1.privKeyTweakAdd(first, second) - fun signString( - message: String, - privKey: ByteArray, - nonce: ByteArray = random.nextBytes(32), - ): ByteArray = sign(CryptoUtils.sha256(message.toByteArray()), privKey, nonce) + fun pubKeyTweakMulCompact( + pubKey: ByteArray, + privateKey: ByteArray, + ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt index 085856d6f7..8cfb1ce11d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt @@ -26,6 +26,10 @@ val pointerSizeInBytes = 4 fun String.bytesUsedInMemory(): Long = (8 * (((this.length * 2L) + 45) / 8)) +fun Long.bytesUsedInMemory(): Long = 8 + +fun Int.bytesUsedInMemory(): Long = 4 + fun Boolean.bytesUsedInMemory(): Long = 8 fun String.containsIgnoreCase(term: String): Boolean { 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 02888e38c4..8193397795 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.quartz.utils -import com.vitorpamplona.quartz.CryptoUtils - object TimeUtils { const val ONE_MINUTE = 60 const val FIVE_MINUTES = 5 * ONE_MINUTE @@ -57,5 +55,5 @@ object TimeUtils { fun oneMonthAgo() = now() - ONE_MONTH - fun randomWithTwoDays() = System.currentTimeMillis() / 1000 - CryptoUtils.randomInt(twoDays()) + fun randomWithTwoDays() = now() - RandomInstance.int(twoDays()) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/UriReferenceExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/UriReferenceExt.kt new file mode 100644 index 0000000000..022055ca29 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/UriReferenceExt.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import org.czeal.rfc3986.URIReference + +fun URIReference.toStringSchemeHost(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (authority != null) sb.append("//").append(authority.toString()) + + return sb.toString() +} + +fun URIReference.toStringNoFragment(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (authority != null) sb.append("//").append(authority.toString()) + if (path != null) sb.append(path) + if (query != null) sb.append("?").append(query) + + return sb.toString() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Sha256.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256.kt similarity index 83% rename from quartz/src/main/java/com/vitorpamplona/quartz/utils/Sha256.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256.kt index 5eda7da51a..9bd9a065f7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Sha256.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256.kt @@ -18,11 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.utils +package com.vitorpamplona.quartz.utils.sha256 -import java.security.MessageDigest +val pool = Sha256Pool(5) // max parallel operations -fun sha256Hash(data: ByteArray): ByteArray { - // Creates a new buffer every time - return MessageDigest.getInstance("SHA-256").digest(data) -} +fun sha256(data: ByteArray) = pool.hash(data) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.kt new file mode 100644 index 0000000000..533151a931 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.sha256 + +import java.security.MessageDigest + +class Sha256Hasher { + val digest = MessageDigest.getInstance("SHA-256") + + fun hash(byteArray: ByteArray) = digest.digest(byteArray).also { digest.reset() } + + fun digest(byteArray: ByteArray) = digest.digest(byteArray) + + fun reset() = digest.reset() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.kt new file mode 100644 index 0000000000..5b31af6fdc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.sha256 + +import android.util.Log +import java.util.concurrent.ArrayBlockingQueue + +class Sha256Pool( + size: Int, +) { + private val pool = ArrayBlockingQueue(size) + + init { + repeat(size) { + pool.add(Sha256Hasher()) + } + } + + private fun acquire(): Sha256Hasher { + if (pool.size < 1) { + Log.w("SHA256Pool", "Pool running low in available digests") + } + return pool.take() + } + + private fun release(digest: Sha256Hasher) { + digest.reset() + pool.put(digest) + } + + fun hash(byteArray: ByteArray): ByteArray { + val hasher = acquire() + try { + return hasher.digest(byteArray) + } finally { + release(hasher) + } + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/experimental/Nip01SerializerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/experimental/Nip01SerializerTest.kt deleted file mode 100644 index 893413480d..0000000000 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/experimental/Nip01SerializerTest.kt +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip01Core.experimental - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.quartz.nip01Core.EventHasher -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.generateId -import junit.framework.TestCase.assertEquals -import org.junit.Test - -class Nip01SerializerTest { - val specialEncoders = - "Test\b\bTest\n\nTest\t\tTest\u000c\u000cTest\r\rTest\\Test\\\\Test\"Test/Test//Test" - - @Test() - fun fastJsonEncoderTest() { - val mapper = Nip01Serializer.StringWriter() - val expected = jacksonObjectMapper().writeValueAsString(specialEncoders) - - Nip01Serializer().escapeStringInto(specialEncoders, mapper) - val encoded = mapper.toString() - assertEquals(expected, "\"" + encoded + "\"") - } - - val payload2 = - """ -{ - "content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", - "created_at": 1683596206, - "id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758", - "kind": 1, - "pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", - "sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", - "tags": [ - [ - "e", - "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3", - "", - "root" - ], - [ - "e", - "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc", - "", - "reply" - ], - [ - "p", - "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" - ], - [ - "p", - "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" - ], - [ - "p", - "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" - ], - [ - "p", - "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2" - ], - [ - "p", - "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac" - ], - [ - "p", - "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" - ], - [ - "p", - "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" - ], - [ - "p", - "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" - ], - [ - "p", - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" - ] - ], - "seenOn": [ - "wss://nostr.wine/" - ] -} -""" - - @Test() - fun fastEventSerializerTest() { - val event = Event.fromJson(payload2) - val mapper = Nip01Serializer.StringWriter() - - Nip01Serializer().serializeEventInto(event, mapper) - - val encoded = mapper.toString() - val eventJson = EventHasher.makeJsonForId(event.pubKey, event.createdAt, event.kind, event.tags, event.content) - - assertEquals(eventJson, encoded) - } - - @Test() - fun fastEventIdCheckTest() { - val event = Event.fromJson(payload2) - - assertEquals("98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758", event.generateId()) - } - - val payload3 = """ - { - "id": "6cccb576158965cf0f06fb4e476f85a02f0011ae783a4e905126a3db3871e43d", - "pubkey": "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74", - "created_at": 1698062466, - "kind": 14, - "tags": [ - [ - "p", - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" - ] - ], - "content": "Oh yeah I hadn't seen this ", - "sig": "" -}""" - - @Test() - fun fastEventSerializerTestPayload3() { - val event = Event.fromJson(payload3) - - val mapper = Nip01Serializer.StringWriter() - - Nip01Serializer().serializeEventInto(event, mapper) - - val encoded = mapper.toString() - val eventJson = EventHasher.makeJsonForId(event.pubKey, event.createdAt, event.kind, event.tags, event.content) - - assertEquals(eventJson, encoded) - } - - @Test() - fun fastEventIdCheckTestPayload3() { - val event = Event.fromJson(payload3) - - assertEquals("6cccb576158965cf0f06fb4e476f85a02f0011ae783a4e905126a3db3871e43d", event.generateId()) - } - - val payload4 = "{\"id\":\"5fd48fd3fb2890a00538067869306d788ff4331896360dc9c7e43d43e01b481b\",\"pubkey\":\"9770fb48aa3861dd393eb857e740f2df6f18e0ead43bad1d30c65e5c198200a6\",\"created_at\":1701673247,\"kind\":1,\"tags\":[[\"imeta\",\"url https://image.nostr.build/790b061d9661df88b06feb7448694cc421671da42217ca8381f02b4def63707f.jpg\",\"blurhash enF~?FRpNbkBjG.AkCbHfkafx_R+V^V_WVt9WBf6axoeoJf4bXWBaz\",\"dim 1536x2048\"],[\"imeta\",\"url https://video.nostr.build/3dd8562e8306c3128a72dee888c0fe587c2b24b8b643ae19b82010aaab95c37c.mp4\",\"blurhash e5A0~^S4R#W,j]~XR%s;o4j?s*M|t3t6ayxot9RiV[RjEH%3WBR%xb\",\"dim 720x1280\"],[\"imeta\",\"url https://image.nostr.build/9249f13cb8df68e00706f4f1434b8f0cd731b0515479df06353a0aef7a798619.jpg\",\"blurhash egFFpp\$LV?kCjs.TRiaej[fP9xIpoKf6fksls+WBbHj[xZn\$WWofjt\",\"dim 1920x3412\"],[\"t\",\"iceland\"],[\"r\",\"https://image.nostr.build/790b061d9661df88b06feb7448694cc421671da42217ca8381f02b4def63707f.jpg\"],[\"r\",\"https://video.nostr.build/3dd8562e8306c3128a72dee888c0fe587c2b24b8b643ae19b82010aaab95c37c.mp4\"],[\"r\",\"https://image.nostr.build/9249f13cb8df68e00706f4f1434b8f0cd731b0515479df06353a0aef7a798619.jpg\"]],\"content\":\"Icelandic calm to your heart \uD83D\uDC9A\uD83D\uDE0C\\n\\n(Memories from this summer)\\n\\n#Iceland , 2023 https://image.nostr.build/790b061d9661df88b06feb7448694cc421671da42217ca8381f02b4def63707f.jpg https://video.nostr.build/3dd8562e8306c3128a72dee888c0fe587c2b24b8b643ae19b82010aaab95c37c.mp4 https://image.nostr.build/9249f13cb8df68e00706f4f1434b8f0cd731b0515479df06353a0aef7a798619.jpg \",\"sig\":\"d6410be4b47bc97fca486eb619dd2507e7332bcd1049e405a047c90eedd2be46007c09d7702361b8442df78932e5da4055ee1c5fef08f938a4d39d828dc20957\"}" - - @Test() - fun fastEventSerializerTestPayload4() { - val event = Event.fromJson(payload4) - - val mapper = Nip01Serializer.StringWriter() - - Nip01Serializer().serializeEventInto(event, mapper) - - val encoded = mapper.toString() - val eventJson = EventHasher.makeJsonForId(event.pubKey, event.createdAt, event.kind, event.tags, event.content) - - assertEquals(eventJson, encoded) - } - - @Test() - fun fastEventIdCheckTestPayload4() { - val event = Event.fromJson(payload4) - - // assertEquals(event.generateId(), event.generateId2()) - assertEquals("5fd48fd3fb2890a00538067869306d788ff4331896360dc9c7e43d43e01b481b", event.generateId()) - } - - val payload5 = "{\"id\":\"d1f097d3d9fcfb00df0c8ab5469be6484b14707d1e947c574ed636281d8dfd26\",\"pubkey\":\"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319\",\"created_at\":1706435280,\"kind\":4550,\"tags\":[[\"a\",\"34550:026d8b7e7bcc2b417a84f10edb71b427fe76069905090b147b401a6cf60c3f27:Catholic\",\"wss://christpill.nostr1.com\"],[\"e\",\"0b8e4fade30fdb57f3887da224682fe9756ee79c408961e46393555bb0367022\"],[\"p\",\"026d8b7e7bcc2b417a84f10edb71b427fe76069905090b147b401a6cf60c3f27\"],[\"k\",\"1\"]],\"content\":\"{\\\"id\\\":\\\"0b8e4fade30fdb57f3887da224682fe9756ee79c408961e46393555bb0367022\\\",\\\"pubkey\\\":\\\"026d8b7e7bcc2b417a84f10edb71b427fe76069905090b147b401a6cf60c3f27\\\",\\\"created_at\\\":1698838786,\\\"kind\\\":1,\\\"tags\\\":[[\\\"a\\\",\\\"34550:026d8b7e7bcc2b417a84f10edb71b427fe76069905090b147b401a6cf60c3f27:Catholic\\\",\\\"\\\",\\\"reply\\\"],[\\\"t\\\",\\\"catholic\\\"],[\\\"t\\\",\\\"catholic\\\"]],\\\"content\\\":\\\"It's so funny at Mass, when you're a sacristan or something because everyone watches and emulates you, so if you forget to stand or kneel at the right moment, everyone remains seated.\\\\n\\\\nAnd when you're like, Oh woops! And stand up, there's a loud wave of people suddenly standing up, too. \uD83D\uDE02\\\\n\\\\n#catholic\\\",\\\"sig\\\":\\\"92c087e6364dd6c1fbf3bf5baddd66f0d86019fb3ba95e36135345d7c8b137f2147de8d628ec974eb493c2dbce2ca581c56c146d282f06a930280c7addc4d021\\\"}\",\"sig\":\"d67c067a08879138989275cb6f062f58e8a523b192bff5e65340becbac05060bb9c7ac6f75727c8fd2229f95a54fe404d18971950a72c1f17618535e7495e09d\"}" - - @Test() - fun fastEventSerializerTestPayload5() { - val event = Event.fromJson(payload5) - - val mapper = Nip01Serializer.StringWriter() - - Nip01Serializer().serializeEventInto(event, mapper) - - val encoded = mapper.toString() - val eventJson = EventHasher.makeJsonForId(event.pubKey, event.createdAt, event.kind, event.tags, event.content) - - assertEquals(eventJson, encoded) - } - - @Test() - fun fastEventIdCheckTestPayload5() { - val event = Event.fromJson(payload5) - - assertEquals("d1f097d3d9fcfb00df0c8ab5469be6484b14707d1e947c574ed636281d8dfd26", event.generateId()) - } -} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/BloomFilterMurMur3Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/BloomFilterMurMur3Test.kt new file mode 100644 index 0000000000..17d99b277b --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/BloomFilterMurMur3Test.kt @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.hints.bloom.BloomFilterMurMur3 +import com.vitorpamplona.quartz.utils.RandomInstance +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class BloomFilterMurMur3Test { + val testEncoded = "100:10:AKiEIEQKALgRACEABA==:3" + val testInBinary = "00000000000101010010000100000100001000100101000000000000000111011000100000000000100001000000000000100000000000000000000000000000" + + val key1 = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b".hexToByteArray() + val key2 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c".hexToByteArray() + val key3 = "560c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c".hexToByteArray() + + val keys = + mutableListOf().apply { + for (seed in 0..1_000_000) { + add(RandomInstance.bytes(32)) + } + } + + @Test + fun testCreate() { + val bloomFilter = BloomFilterMurMur3(100, 10, commonSalt = 3) + bloomFilter.add(key1) + bloomFilter.add(key2) + + assertEquals(testEncoded, bloomFilter.encode()) + assertEquals(testInBinary, bloomFilter.printBits()) + + assertTrue(bloomFilter.mightContain(key1)) + assertTrue(bloomFilter.mightContain(key2)) + + assertFalse(bloomFilter.mightContain(key3)) + } + + @Test + fun testDecoding() { + val bloomFilter = BloomFilterMurMur3.decode(testEncoded) + + assertEquals(testEncoded, bloomFilter.encode()) + assertEquals(testInBinary, bloomFilter.printBits()) + + assertTrue(bloomFilter.mightContain(key1)) + assertTrue(bloomFilter.mightContain(key2)) + + assertFalse(bloomFilter.mightContain(key3)) + } + + @Test + fun runProb() { + val bloomFilter = BloomFilterMurMur3.decode(testEncoded) + + var failureCounter = 0 + repeat(1_000_000) { + if (bloomFilter.mightContain(RandomInstance.bytes(32))) { + failureCounter++ + } + } + assertEquals(0, failureCounter) + } + + @Test + fun runProb2() { + val bloomFilter = BloomFilterMurMur3(10_000_000, 4) + keys.forEach(bloomFilter::add) + + var failureCounter = 0 + repeat(1_000_000) { + if (bloomFilter.mightContain(RandomInstance.bytes(32))) { + failureCounter++ + } + } + assertTrue("Failures $failureCounter ${failureCounter / 1_000_000.0}", failureCounter / 1_000_000.0f < 0.015) + } + + @Test + fun runProb3() { + val bloomFilter = BloomFilterMurMur3(10_000_000, 4) + keys.forEach(bloomFilter::add) + + var failureCounter = 0 + repeat(1_000_000) { + if (bloomFilter.mightContain(RandomInstance.bytes(32))) { + failureCounter++ + } + } + assertTrue("Failures $failureCounter ${failureCounter / 1_000_000.0}", failureCounter / 1_000_000.0f < 0.015) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt new file mode 100644 index 0000000000..49b463648a --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt @@ -0,0 +1,162 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexerTest.Companion.indexer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.usedMemoryMb +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class HintIndexerTest { + companion object { + val VALID_CHARS: List = ('0'..'9') + ('a'..'z') + ('A'..'Z') + + private fun randomChars(size: Int): String = CharArray(size) { VALID_CHARS.random() }.concatToString() + + val keys = + mutableListOf().apply { + for (seed in 0..1_000_000) { + add(RandomInstance.bytes(32).toHexKey()) + } + } + + val eventIds = + mutableListOf().apply { + for (seed in 0..1_000_000) { + add(RandomInstance.bytes(32).toHexKey()) + } + } + + val addresses = + keys.take(100_000).map { + Address.assemble( + RandomInstance.int(65_000), + it, + randomChars(10), + ) + } + + val relays = + this::class.java + .getResourceAsStream("relayDB.txt") + ?.readAllBytes() + .toString() + .split("\n") + + val indexer by lazy { + System.gc() + Thread.sleep(1000) + + val startingMemory = Runtime.getRuntime().usedMemoryMb() + val result = HintIndexer() + val endingMemory = Runtime.getRuntime().usedMemoryMb() + + println("Filter using ${endingMemory - startingMemory}MB") + + // Simulates 5 outbox relays for each key + keys.forEach { key -> + (0..5).map { + result.addKey(key, relays.random()) + } + } + + // Simulates each event being in 8 + 0..10 relays. + eventIds.forEach { id -> + repeat(8 + RandomInstance.int(10)) { + result.addEvent(id, relays.random()) + } + } + + // Simulates each address being in 8 + 0..10 relays. + addresses.forEach { address -> + repeat(8 + RandomInstance.int(10)) { + result.addAddress(address, relays.random()) + } + } + + result + } + } + + val testSize = 1000 + val testProb = 0.02f + + fun assert99PercentSucess(success: () -> Boolean) { + var failureCounter = 0 + repeat(testSize) { + if (!success()) { + failureCounter++ + } + } + + assertTrue( + "Failure rate: $failureCounter of 1000 elements => ${(failureCounter / testSize.toFloat()) * 100}%", + failureCounter / testSize.toFloat() < testProb, + ) + } + + @Test + fun runProbExistingKeys() = + assert99PercentSucess { + indexer.getKey(keys.random()).isNotEmpty() + } + + @Test + fun runProbNewKeys() = + assert99PercentSucess { + indexer.getKey(RandomInstance.bytes(32)).isEmpty() + } + + @Test + fun runProbExistingEventIds() = + assert99PercentSucess { + indexer.getEvent(eventIds.random()).isNotEmpty() + } + + @Test + fun runProbNewEventIds() = + assert99PercentSucess { + indexer.getEvent(RandomInstance.bytes(32)).isEmpty() + } + + @Test + fun runProbExistingAddresses() = + assert99PercentSucess { + indexer.getAddress(addresses.random()).isNotEmpty() + } + + @Test + fun runProbNewAddresses() = + assert99PercentSucess { + val newAddress = + Address.assemble( + RandomInstance.int(65000), + RandomInstance.bytes(32).toHexKey(), + randomChars(10), + ) + + indexer.getAddress(newAddress).isEmpty() + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/MurMur3Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/MurMur3Test.kt new file mode 100644 index 0000000000..195099cb41 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/MurMur3Test.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.hints + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.hints.bloom.MurmurHash3 +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class MurMur3Test { + class Case( + val bytesHex: HexKey, + val seed: Int, + val result: Int, + ) + + val testCases = + listOf( + Case("9fd4e9a905ca9e1a3086fa4c0a1ed829dbf18c15ec05af95c76b78d3d2f5651b", 886838366, -525456393), + Case("e6c8f70f0d35a983bfebd00e5f29787c009c52971cfb4ac3a49b534b256b59cc", 1717487548, 1605080838), + Case("7f7113833feb31e877f193e2fc75a64e9c70252c3ae3c73373ff34430ae40ea6", 1275582690, 225480992), + Case("61770be6ec9df0f490743318e796e28ae34609732b61d365947871532d77d697", 514559346, 1424957638), + Case("375f46b4687ba3cd035db303fa294d943816e64ca6b3adcda2ae40e8ac9d91a0", 1898708424, 1730418066), + Case("c67044cd1d07a2aeb92b7bec973b6feb8abb9197840c59c101cacaa992489d49", 294602161, -1944496371), + Case("49db4bfcc4da62e38c4076843cdde1425570806f09f121f5e7f2507c5ee1db85", 910710684, 944243368), + Case("c5e98a30dead5ade4900b26eabae3435cfcdb64ff5e55c99641915a0c6ee73fc", 1107230285, 1550302684), + Case("b0ed2e7568e6b4e1d5e5bab46fde01149331b824e48a281798d7216dde8f5890", 1013875681, -1265544300), + Case("805f290e865bde094d77e82fb8b338d83347bc5449a4aed9fb08afb6a53a079b", 1674416787, -1821262025), + // special cases + Case("805f290e865bde094d77e82fb8b338d83347bc5449a4aed9fb08afb6a53a079b", Int.MAX_VALUE, -422576759), + Case("805f290e865bde094d77e82fb8b338d83347bc5449a4aed9fb08afb6a53a079b", Int.MAX_VALUE + 1, 851385048), + Case("805f290e865bde094d77e82fb8b338d83347bc5449a4aed9fb08afb6a53a079b", Int.MIN_VALUE, 851385048), + Case("805f290e865bde094d77e82fb8b338d83347bc5449a4aed9fb08afb6a53a079b", 0, 1615518380), + Case("fd", 1, 975430984), + Case("00", 1, 0), + Case("FF", 1, -797126820), + Case("3033", 1, 1435178296), + Case("0000", 1, -2047822809), + Case("FFFF", 1, 1459517456), + Case("3652a8", 1, 103723868), + Case("000000", 1, 821347078), + Case("FFFFFF", 1, -761438248), + Case("00000000", 1, 2028806445), + Case("FFFFFFFF", 1, 919009801), + ) + + @Test + fun testMurMur() { + val hasher = MurmurHash3() + testCases.forEach { + assertEquals( + it.result, + hasher.hash(it.bytesHex.hexToByteArray(), it.seed), + ) + } + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/relayDB.txt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/relayDB.txt new file mode 100644 index 0000000000..0977f44b3a --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/relayDB.txt @@ -0,0 +1,2539 @@ +wss://nostr.wine +wss://relay.orangepill.dev +wss://xmr.usenostr.org +wss://nostr.portemonero.com +wss://nostr.xmr.rocks +wss://relay.nostr.band +wss://filter.nostr.wine +wss://nostr.milou.lol +wss://nostr.mutinywallet.com +wss://nostr-pub.wellorder.net +wss://nostr.zebedee.cloud +wss://nos.lol +wss://brb.io +wss://bitcoiner.social +wss://nostr.decentony.com +wss://relay.nostriches.org +wss://paid.spore.ws +wss://eden.nostr.land +wss://puravida.nostr.land +wss://5dzvuefllevkhk7miqynaviguedxfnofrayu2xwfwtlkdg4radjdlyqd.onion +wss://relay-jp.nostr.wirednet.jp +wss://relay.nostrich.land +wss://nostr.holybea.com +wss://nostr-relay.nokotaro.com +wss://nostr-paid.h3z.jp +wss://nostrja-kari.heguro.com +wss://nostr.mom +wss://nostr.fediverse.jp +wss://nostr.h3z.jp +wss://universe.nostrich.land +wss://nostr.uselessshit.co +wss://atlas.nostr.land +wss://relay.snort.social +wss://universe.nostrich.landlangenlanges +wss://nostr.slothy.win +wss://nostr.plebchain.org +wss://nostr-relay.untethr.me +wss://relay.nostr.com.au +wss://nostr.inosta.cc +wss://relay.nostrati.com +wss://nostr.bitcoiner.social +wss://relay.nostrplebs.com +wss://relay.nostr.info +wss://nostr-relay.wlvs.space +wss://nostr.oxtr.dev +wss://nostr.onsats.org +wss://relay.wellorder.net +wss://relay.plebstr.com +wss://no.str.cr +wss://nostr.walletofsatoshi.com +wss://nostr.mwmdev.com +wss://relay.nostr.bg +wss://nostr.rocks +wss://nostr.fmt.wiz.biz +wss://nostr.orangepill.dev +wss://nostr-pub.semisol.dev +wss://nostr.sandwich.farm +wss://relay.nostr.ch +wss://relay.orange-crush.com +wss://private.red.gb.net +wss://nostr.lnprivate.network +wss://nostr.lu.ke +wss://relay.nostr.wirednet.jp +wss://lightningrelay.com +wss://relay.nostrgraph.net +wss://relay.nostrica.com +wss://relay.mostr.pub +wss://nostr-sg.com +wss://nostr.zkid.social +wss://relay.nostr.vet +wss://relay.nostr3.io +wss://relay.arsip.my.id +wss://relay.current.fyi +wss://global.relay.red +wss://nostr.island.network +wss://node01.nostress.cc +wss://relay.nostr.net.in +wss://relay.utxo.one +wss://relay-1.arsip.my.id +wss://nostrical.com +wss://nostro.cc +wss://rsslay.nostr.moe +wss://relay.nostr.or.jp +wss://nostream.unift.xyz +wss://blastr.f7z.xyz +wss://relay.honk.pw +wss://universe.nostrich.landlangja +wss://nostream.ocha.one +wss://paid-relay.nost.love +wss://offchain.pub +wss://nostr-usa.ka1gbeoa21bnm.us-west-2.cs.amazonlightsail.com +wss://nostr.terminus.money +wss://nostr.shawnyeager.net +wss://public.nostr.swissrouting.com +wss://nostrex.fly.dev +wss://relay.727whisky.com +wss://relay.cryptocculture.com +wss://relay.bleskop.com +wss://nostr.lorentz.is +wss://nostr.actn.io +wss://nostr-relay.lnmarkets.com +wss://nostr.openchain.fr +wss://relay.punkhub.me +wss://nostr.blipme.app +wss://nostr.swiss-enigma.ch +wss://nostr-verified.wellorder.net +wss://at.nostrworks.com +wss://21sats.net +wss://e.nos.lol +wss://nostr.mikedilger.com +wss://nostr.azte.co +wss://noster.bitcoiner.social +wss://relay.nostr.moe +wss://nostream.nostrly.io +wss://nostr.gives.africa +wss://nostr.21sats.net +wss://bitcoinmaximalists.online +wss://paid.nostrified.org +wss://nostr.1sat.org +wss://nostr.relayer.se +wss://sg.qemura.xyz +wss://nostr.coinos.io +wss://nostr.bitcoinplebs.de +wss://nostrich.friendship.tw +wss://nostr.sg +wss://eosla.com +wss://nostr.sidnlabs.nl +wss://nostr.foundrydigital.com +wss://relay.nostrview.com +wss://nostr.semisol.dev +wss://relay.f7z.io +wss://wlvs.space +wss://nostr.v0l.io +wss://nostr-relay.digitalmob.ro +wss://rsslay.fiatjaf.com +wss://relay.theorangepillapp.com +wss://relay.zeh.app +wss://nostr.zoomout.chat +wss://relay.stoner.com +wss://nostr.cercatrova.me +wss://relay.ryzizub.com +wss://nostr-1.nbo.angani.co +wss://nostr21.com +wss://spore.ws +wss://nostrue.com +wss://no-str.org +wss://relay.taxi +wss://ragnar-relay.com +wss://relay.austrich.net +wss://relay.nostr-latam.link +wss://1.noztr.com +wss://relay.nostr.scot +wss://test.relay.nostrich.day +wss://jiggytom.ddns.net +wss://nostr.bongbong.com +wss://relay.nostromo.social +wss://relay.sendstr.com +wss://nostr-dev.universalname.space +wss://relay.nostrcheck.me +wss://nostr.libertasprimordium.com +wss://nostr.kollider.xyz +wss://expensive-relay.fiatjaf.com +wss://nostr-sandbox.minds.io +wss://relay.nostrich.de +wss://nostr.gromeul.eu +wss://relay.nostr.wine +wss://nostr.screaminglife.io +wss://nostr-relay.derekross.me +wss://nostrica.nostrnotes.com +wss://paid.no.str.cr +wss://nostr.sethforprivacy.com +wss://nostr.dumpit.top +wss://nostr.herci.one +wss://cheery-paddock-rsakdrtc35c55n6yregn.wnext.app +wss://nostr.blockpower.capital +wss://nostr.nym.life +wss://nostr-verif.slothy.win +wss://fiatdenier.com +wss://nostr.bitcoin-21.org +wss://nostr.fluidtrack.in +wss://nostr.developer.li +wss://r.ayit.org +wss://relay.nostr.nu +wss://nostr.bostonbtc.com +wss://rly.social +wss://nostr.bridgey.dev +wss://relay.nostrprotocol.net +wss://nostr.mado.io +wss://nostr.einundzwanzig.space +wss://nostr2.actn.io +wss://nostr-relay.freedomnode.com +wss://nostr.pleb.network +wss://nostr.mouton.dev +wss://eelay.current.fyi +wss://nostr.notmyhostna.me +wss://nostr.pjv.me +wss://nostr.jatm.link +wss://nostr.fractalized.ovh +wss://nostr-relay.app.ikeji.ma +wss://relayer.ocha.one +wss://nostr.com.de +wss://nostr-2.afarazit.eu +wss://nostr.l00p.org +wss://nostr.drss.io +wss://relay.nostrify.io +wss://nostr.radixrat.com +wss://nostr-relay.bitcoin.ninja +wss://nostrsatva.net +wss://nostr.mustardnodes.com +wss://nostr01.vida.dev +wss://nostr.noones.com +wss://nostr.easify.de +wss://nostr3.actn.io +wss://moonbreeze.richardbondi.net +wss://nostr.naut.social +wss://private-nostr.v0l.io +wss://nostr.zaprite.io +wss://nostr.lightninglinks.xyz +wss://nostr.hackerman.pro +wss://nr.yay.so +wss://nostr.roundrockbitcoiners.com +wss://nostr.sovbit.host +wss://nostrelay.yeghro.site +wss://pow32.nostr.land +wss://nostr.1729.cloud +wss://nostr.rdfriedl.com +wss://nostr.h4x0r.host +wss://nostr.up.railway.app +wss://nostr.lnorb.com +wss://nostr.lordkno.ws +wss://relay.nostr.vision +wss://nostr-3.orba.ca +wss://satstacker.cloud +wss://freedom-relay.herokuapp.com +wss://nostr-relay.freeberty.net +wss://nostr.unknown.place +wss://nostr.delo.software +wss://relay.nostr.pro +wss://relay.minds.com +wss://nostr.ono.re +wss://relay.grunch.dev +wss://relay.cynsar.foundation +wss://relay.oldcity-bitcoiners.info +wss://relay.bitid.nz +wss://relay.nostr.xyz +wss://relay.futohq.com +wss://relay.farscapian.com +wss://astral.ninja +wss://relay.sovereign-stack.org +wss://nostr-2.zebedee.cloud +wss://nostr.nymsrelay.com +wss://relay.kronkltd.net +wss://relay.r3d.red +wss://universe.nostrich.landlangen +wss://nostr-dev.wellorder.net +wss://nostr.beta3.dev +wss://nostr.data.haus +wss://nostr.hugo.md +wss://relay-dev.cowdle.gg +wss://relay.dwadziesciajeden.pl +wss://tmp-relay.cesc.trade +wss://nostr.massmux.com +wss://relay.nostr.africa +wss://nostr1.tunnelsats.com +wss://nostr.f44.dev +wss://relay.n057r.club +wss://nostr-verif.slothy.com +wss://nostr.1f52b.xyz +wss://nostr.sebastix.dev +wss://nostr.lightning.contact +wss://nostr.rly.social +wss://noster.online +wss://relay.lexingtonbitcoin.org +wss://nostr.bitcoinbay.engineering +wss://nostr.howtobitcoin.shop +wss://blg.nostr.sx +wss://deschooling.us +wss://foolay.nostr.moe +wss://freespeech.casa +wss://nostr-01.bolt.observer +wss://nostr-01.dorafactory.org +wss://nostr-au.coinfundit.com +wss://nostr-eu.coinfundit.com +wss://nostr-relay.alekberg.net +wss://nostr-pub1.southflorida.ninja +wss://nostr-relay.gkbrk.com +wss://nostr-relay.pcdkd.fyi +wss://nostr-relay.schnitzel.world +wss://nostr-us.coinfundit.com +wss://nostr.21crypto.ch +wss://nostr.600.wtf +wss://nostr.8e23.net +wss://nostr.app.runonflux.io +wss://nostr.arguflow.gg +wss://nostr.bch.ninja +wss://nostr.chainofimmortals.net +wss://nostr.cizmar.net +wss://nostr.cheeserobot.org +wss://nostr.coollamer.com +wss://nostr.corebreach.com +wss://nostr.cro.social +wss://nostr.easydns.ca +wss://nostr.globals.fans +wss://nostr.handyjunky.com +wss://nostr.itas.li +wss://nostr.sectiontwo.org +wss://nostr.spleenrider.one +wss://nostr.thibautrey.fr +wss://nostr.uthark.com +wss://nostr.vulpem.com +wss://nostr.w3ird.tech +wss://nostr.whoop.ph +wss://nostr.yuv.al +wss://nostr01.opencult.com +wss://nostre.cc +wss://nostream.denizenid.com +wss://nostring.deno.dev +wss://pow.nostrati.com +wss://relay-pub.deschooling.us +wss://nostr.jiashanlu.synology.me +wss://nostr.klabo.blog +wss://relay.valireum.net +wss://nostr.fly.dev +wss://nostr.nordlysln.net +wss://nostr.zerofeerouting.com +wss://rsslay.nostr.net +wss://nostr-relay.nonce.academy +wss://nostr.rewardsbunny.com +wss://lv01.tater.ninja +wss://nostr-2.orba.ca +wss://nostr.orba.ca +wss://nostr.supremestack.xyz +wss://nostrrelay.com +wss://relay.nostr.au +wss://nostr.oooxxx.ml +wss://nostr.yael.at +wss://nostr-relay.trustbtc.org +wss://nostr.namek.link +wss://nostr-relay.wolfandcrow.tech +wss://nostr.satsophone.tk +wss://relay.dev.kronkltd.net +wss://nostr2.namek.link +wss://relay.21spirits.io +wss://relay.minds.io +wss://nostr.d11n.net +wss://nostr.tunnelsats.com +wss://nostr.leximaster.com +wss://mule.platanito.org +wss://nostr.robotechy.com +wss://relay.nostrmoto.xyz +wss://relay.boring.surf +wss://nostr.gruntwerk.org +wss://nostr.hyperlingo.com +wss://nostr.ethtozero.fr +wss://nostr.nodeofsven.com +wss://nostr.jimc.me +wss://nostr.utxo.lol +wss://relay.nyx.ma +wss://nostr.shmueli.org +wss://wizards.wormrobot.org +wss://nostr.sovbit.com +wss://nostr.datamagik.com +wss://relay.nostrid.com +wss://nostr1.starbackr.me +wss://relay.nostr.express +wss://nostr.formigator.eu +wss://nostr.xpersona.net +wss://nostr.digitalreformation.info +wss://nostr-relay.usebitcoin.space +wss://nostr-alpha.gruntwerk.org +wss://nostr-relay.australiaeast.cloudapp.azure.com +wss://nostr-relay.smoove.net +wss://nostr-relay.j3s7m4n.com +wss://nostr.demovement.net +wss://nostr.thesimplekid.com +wss://nostr.aozing.com +wss://nostr.blocs.fr +wss://no.str.watch +wss://btc.klendazu.com +wss://nostr.mrbits.it +wss://nostr.zenon.wtf +wss://no.contry.xyz +wss://nostream.gromeul.eu +wss://relay.nostr.ro +wss://nostr.ncsa.illinois.edu +wss://nostr.itssilvestre.com +wss://nostr.chaker.net +wss://knostr.neutrine.com +wss://nostr.pobblelabs.org +wss://nostr.simatime.com +wss://relay.nosphr.com +wss://student.chadpolytechnic.com +wss://nostr.localhost.re +wss://nostr.coinsamba.com.br +wss://deconomy-netser.ddns.net:2121 +wss://nostr.21m.fr +wss://zur.nostr.sx +wss://nostr-relay.texashedge.xyz +wss://spleenrider.herokuapp.com +wss://nostr.bitcoin.sex +wss://relay.nostrzoo.com +wss://nostr.blockchaincaffe.it +wss://nostr-bg01.ciph.rs +wss://knostr.neutrine.com:8880 +wss://nostr.ahaspharos.de +wss://nostr.argdx.net +wss://nostr.snblago.com +wss://merrcurr.up.railway.app +wss://nostr.bingtech.tk +wss://relay.nostr.wf +wss://relay.koreus.social +wss://nostr.randomdevelopment.biz +wss://relay.nostr.hu +wss://relay.nostr.lu +wss://relay.nostr.ae +wss://middling.myddns.me:8080 +wss://nostr.nikolaj.online +wss://relay.nostrology.org +wss://nostr.satoshi.fun +wss://nostream.kinchie.snowinning.com +wss://nostr.lapalomilla.mx +wss://relay.thes.ai +wss://rsr.uyky.net:30443 +wss://nostrafrica.pcdkd.fyi +wss://nostr.bitcoin-basel.ch +wss://relay.21baiwan.com +wss://nostr.ddns.net:8008 +wss://free-relay.nostrich.land +wss://nostr.lukeacl.com +wss://nostr.ddns.net +wss://nostr.rocket-tech.net +wss://nostr-1.afarazit.eu +wss://nostr.0nyx.eu +wss://nostr-mv.ashiroid.com +wss://lbrygen.xyz +wss://nostr.community.networks.deavmi.assigned.network +wss://nostr.ownscale.org +wss://relay1.gems.xyz +wss://nostr.soscary.net +wss://nostr.0xtr.dev +wss://damus.io +wss://relay.alien.blue +wss://nostr.btcmp.com +wss://relayer.fiatjaf.com +wss://relay.lacosanostr.com +wss://adult.18plus.social +wss://nostrrr.bublina.eu.org +wss://relay.stoner +wss://nostr.pwnshop.cloud +wss://nostr.directory +wss://nostr-relay-dev.wlvs.space +wss://member.cash +wss://relay.nyc1.vinux.app +wss://nostr-relay.digitamob.ro +wss://nor.st +wss://nostr.topeth.info +wss://nostr.rocketstyle.com.au +wss://relay.tnano.duckdns.org +wss://nostr.21l.st +wss://electra.nostr.land +wss://relay.codl.co +wss://nostr.koning-degraaf.nl +wss://relay.mrjohnsson.net +wss://nostr.thank.eu +wss://relay.stonez.me +wss://relay.nostr.distrl.net +wss://relay.valera.co +wss://api.semisol.dev +wss://nostr.lol +wss://relay.shitforce.one +wss://n-word.sharivegas.com +wss://lamp.wtf +wss://nostr.bitcoinpuertori.co +wss://nostr-01.bolt.oberver +wss://3d515c5277e9.ngrok.io +wss://nostr.xmrk.mooo.com +wss://alphapanda.pro +wss://relays.world +wss://universe.nostrich.landlangzh +wss://arnostr.permadao.io +wss://relay.chenxixian.cn +wss://universe.nostrich.landlangzhlangen +wss://v2r.chenxixian.cn +wss://nostr-relay-test.nokotaro.work +wss://universe.nostrich.landlangjalangen +wss://nostr.risa.zone +wss://relay.nosbin.com +wss://translate.argosopentech.com +wss://edennostr.land +wss://nostr.kawagarbo.xyz +wss://nostr.member.cash +wss://ch1.duno.com +wss://nostream-production-b80e.up.railway.app +wss://relay1.nostrich.cloud +wss://relay.t5y.ca +wss://nostr.zhongwen.world +wss://nostr.p2sh.co +wss://nostr.thomascdnns.com +wss://nostream.simon.snowinning.com +wss://relay.nostr.blockhenge.com +wss://nostr.buythisdip.com +wss://nostrua.com +wss://relay.bigred.social +wss://lingoh.dev +wss://nostr.poster.place +wss://nostr.geekgalaxy.com +wss://oarnx6xdrq5mygfdrbmzsvh3is3holefpz2x4qwbopwcicwd63gcivid.onion +wss://relay.nostropolis.xyz +wss://nostream-production-ba43.up.railway.app +wss://nostr.sabross.xyz +wss://relay.nvote.co +wss://nostrati.com +wss://cloudnull.land +wss://nostr.frennet.xyz +wss://nostr.wine.com +wss://nostr.sactiontwo.org +wss://nostr.liberty.fans +wss://nostr.primz.org +wss://btc-italia.online +wss://homenode.local:4848 +wss://nostr.frennet.xyzl +wss://relay.roosoft.com +wss://rasca.asnubes.art +wss://nostr.bitcoin.sexanewlycre +wss://nostr.barf.bz +wss://nostr.middling.mydns.jp +wss://relay.xuzmail.com +wss://no-str.wnhefei.cn:28443 +wss://quirky-bunch-isubghsvoi26fbbt3n7o.wnext.app +wss://nostr.fennel.org:7000 +wss://nostr.0ne.day +wss://nostr.vpn1.codingmerc.com +wss://nostr.jacany.com +wss://nostream.lucas.snowinning.com +wss://relay.beta.fogtype.com +wss://nostr.zue.news +wss://nostream.madbean.snowinning.com +wss://nostr2.rbel.co +wss://relay.1bps.io +wss://nostream-relay-nostr.831.pp.ua +wss://zee-relay.fly.dev +wss://nostrrelay.geforcy.com +wss://relay.nostr.jhot.me +wss://nostr.itredneck.com +wss://nostr.h3y6e.com +wss://relay.bitcoiner.social +wss://hos.lol +wss://iris.to +wss://nostr-pub.senisol.dev +wss://nostr-pub.wellirder.net +wss://bitcoinforthe.lol +wss://relav.nostr.info +wss://3e32-200-229-144-129.ngrok.io +wss://nostr-relay.hzrd149.com +wss://nostr-world.h3z.jp +wss://nostr.thesamecat.io +wss://nostr.compile-error.net +wss://relayable.org +wss://mostra.milou.lol +wss://nproxy.cc +wss://nostr.bitmatk.io +wss://coracle.social +wss://umbrel.local:4848 +wss://nostr.ownbtc.online +wss://wss.nostrgram.co:444 +wss://nostr.minimue81.selfhost.co +wss://relay.current.fy +wss://nostream.nostr.parts +wss://nostr.zebede.cloud +wss://nostrwhoop.ph +wss://relay.nostrified.org +wss://nproxy.zerologin.co +wss://nostr.pcdkd.fyi +wss://relay.kongerik.et +wss://nostr.eden.land +wss://nostr.retroware.run.place +wss://relay.humanumest.social +wss://bhagos.org +wss://hushvault.ie +wss://nostream-production-9458.up.railway.app +wss://nostr.nakamotosatoshi.cf +wss://globals.fans +wss://nostr.cruncher.com +wss://nostr.global.fan +wss://relay.nostr.snblago.com +wss://nostr.nokotaro.com +wss://ostr-1.afarazit.eu +wss://relay.zhix.in +wss://stats.nostr.band +wss://nostr.fine +wss://vxlw4rlg7go34ol43g4gxbvfu4txdzjauquvnbptzwjflezs3vik55id.onion +wss://big.fist.black +wss://universe.nostrich.landlangzhlangja +wss://relay.plebz.space +wss://nostrich.land +wss://relay.mynostr.fun +wss://swiss-enigma.ch +wss://nostr1.current.fyi +wss://relay.atlas.nostr.land +wss://nostr.band +wss://n.wingu.se +wss://nostr.jmdtx.com +wss://nostrproxy-1.f7z.io +wss://nostr.ch +wss://roundrockbitcoiners.com +wss://nostr.sept.ml +wss://srelay.roli.social +wss://nostr.monostr.com +wss://nostr.dojotunnel.online +wss://nostrica.dojotunnel.online +wss://nostr.shadownode.org +wss://thes.ai +wss://rsslay.wss +wss://nostr.vol.io +wss://nostrgram.co +wss://habla.news +wss://runningnostr.lol +wss://mostr.pub +wss://relay.example2.com +wss://profiles.f7z.io +wss://nostr.adpo.co +wss://jp-relay-nostr.invr.chat +wss://nostr.anchel.nl +wss://mutinywallet.com +wss://relay.nostrbr.online +wss://filter.eden.nostr.land +wss://relay.nostrdocs.com +wss://relay.nostr.lucentlabs.co +wss://n.xmr.se +wss://nostr.relayer.rs +wss://monad.jb55.com:8080 +wss://nostr.watch +wss://universe.nostrich.landlangenlangzhlangja +wss://nostr.asdf.mx +wss://ts.relays.world +wss://arc1.arcadelabs.co +wss://stealth.wine +wss://nostr.bg +wss://really.nostr.bg +wss://realy.nostr.bg +wss://relai.nostr.bg +wss://relay-verified.deschooling.us +wss://4.up.railway.app +wss://nostr-relay.aapi.me +wss://nostr-z9tc.onrender.com +wss://nostrich.site +wss://nostr.ginuerzh.xyz +wss://310b-200-229-144-129.ngrok.io +wss://nostr.aste.co +wss://black.nostrcity.club +wss://nostr.guru +wss://nostrica.com +wss://relay.leesalminen.com +wss://nostr.shroomslab.net +wss://meta-relay-beta.nostr.wirednet.jp +wss://nostr.reamde.dev +wss://nostr.africa +wss://powrelay.xyz +wss://rsslay.data.haus +wss://nostr.danvergara.com +wss://nostr.one.re +wss://dev2.hazilitt.fiatjaf.com +wss://nostr-2.zebdeee.cloud +wss://zerosequioso.com +wss://brb.io.relay +wss://relay.realsearch.cc +wss://nodestr.fmt.wiz.biz +wss://r.alphaama.com +wss://nostr.relay-wlvs.space +wss://relay.21spiritis.io +wss://nostr.pinkanki.org +wss://nostr.damus.io +wss://fin-nostr.seekdisruption.com +wss://relay.nostr.lu.ke +wss://nostr.rdfried.com +wss://nostr.trustbtc.org +wss://nostr.verymad.net +wss://relay.damus.info +wss://relays.nostrplebs.com +wss://nostr.688.org +wss://15171031.688.org +wss://dgi4mb7antpcmrx4rynm6xq52xzt5duvxa4iwucq4mszgpz6smrjajqd.onion +wss://mastodon.cloud +wss://nostr.ch3n2k.com +wss://nostr.forecastdao.com +wss://nostr.nostrelay.org +wss://nostr.robotesc.ro +wss://nostr.test.aesyc.io +wss://nostr.web3infra.xyz +wss://nostrrelay.maciejz.net +wss://nostrsxz4lbwe-nostr.functions.fnc.fr-par.scw.cloud +wss://nostrpurple.com +wss://rsslay.ch3n2k.com +wss://nos.qghs.in +wss://nostr.nordlysln.net:3241 +wss://nostr.net.in +wss://relay.rip +wss://universe.nostrich.landlangenlangja +wss://wmv-vm.local:4848 +wss://relay.austritch.net +wss://relay.oxtr.dev +wss://rwlay.bigred.social +wss://relay.lexongtonbitcoin.org +wss://knostr.neutrine +wss://nostr.online +wss://filter.stealth.wine +wss://nostream-production-5895.up.railway.app +wss://nostr.stereosteve.com +wss://relay01.apus.network +wss://test.nostr.0x50.tech +wss://nostr.0x50.tech +wss://nostr.256k1.dev +wss://nostr.malin.onl +wss://jqiwgflfw4dezjsy42frompmknrlcfazoiyngftgknj7yrmnhtobd7id.local +wss://b.ayit.org +wss://nostrelay.rajabi.ca +wss://off20chain.pub +wss://nostr.milou.land +wss://nostr.primedomain.fr +wss://nostr.theblockreward.com +wss://anon.computer +wss://relay.hamnet.io +wss://nostramsterdam.vpx.moe +wss://global-relay.cesc.trade +wss://btcpay.kukks.org +wss://relay.cent2sat.com +wss://nostr.mnethome.de +wss://nostream.sh4.red +wss://klockenga.social +wss://nostream.megadope.snowinning.com +wss://nostr.cvilleblockchain.org +wss://nostr.bitocial.xyz +wss://nostr.bitcoiner.socail +wss://nostr-relay.eniehack.net +wss://greenart7c3.dedyn.io +wss://nostr.data.naus +wss://8.tcp.ngrok.io:19607 +wss://relay.nostr24.com +wss://d463rbo7dgbfuxvvxpory2og2etl4gttfzmqcixdq7rpts47lpgolkyd.onion +wss://dublin.saoirse.dev +wss://www.weixin.com +wss://nostr-rs-relay.cryptoassetssubledger.com +wss://nostr.kojira.net +wss://nostr.fan +wss://nostr.pk +wss://getalpy.com +wss://billert.xyz +wss://circle-ay.info +wss://jawsh.xyz +wss://walletofsatoshi.com +wss://ogblock.xyz +wss://nodestrich.com +wss://kunigaku.gith +wss://nostr.21ideas.org +wss://133332.xyz +wss://asats.io +wss://nostrchack.me +wss://chalow.net +wss://cashu.me +wss://tsukemonogit.git +wss://h3y6e.com +wss://elder.nostr.land +wss://xmr.rocks +wss://nostr.build +wss://mofumemo.com +wss://kpherox.dev +wss://sb.nostr.band +wss://tyiu.xyz +wss://welkinhere.githu +wss://ocha.one +wss://in.tips +wss://vitorpamplona.co +wss://fiatjaf.com +wss://akiomik.github.io +wss://stacker.news +wss://nvk.org +wss://lordkno.ws +wss://nostr.indus +wss://lotdkno.ws +wss://nosutora.com +wss://milou.lol +wss://mostr.pu +wss://dergigi.com +wss://shirehodl.com +wss://cash.app +wss://h3z.jp +wss://murachue.cytes.net +wss://snowcait.gith +wss://jb55.com +wss://nodeless.io +wss://orange-crush.com +wss://nostr.com.au +wss://oooxxx.ml +wss://plebs.place +wss://ahr999.com +wss://penpenpng.github.io +wss://nostrpurple.co +wss://thank.eu +wss://weep.jp +wss://tigerville.no +wss://nostrcheck.me +wss://frenstr.com +wss://ln.tips +wss://ryumu.dev +wss://honeyroad.store +wss://harlembitcoin.com +wss://f7z.io +wss://relay.fan +wss://nisshiee.org +wss://www.lopp.net +wss://getalby.com +wss://heguro.com +wss://wil.bio +wss://b.tc +wss://nodedttich.com +wss://relay.taldra.in +wss://relat.nostrica.com +wss://nostr.boring.surf +wss://nostr.raitisoja.net +wss://nostr.astrox.app +wss://nostr.mjex.me +wss://slick.mjex.me +wss://nostr.hrmb.org +wss://relay.semaphore.life +wss://rss.nostr.band +wss://63ragcfwb5xhoe5gfflazfyrde3qjdo73cblhhmnbviizowdo2q5haid.onion:5051 +wss://nostrblip.app +wss://relay.vanderwarker.family +wss://relay-local.cowdle.gg +wss://relay.damus.com +wss://relay.reeve.cn +wss://relay.strfry.net +wss://relay.alxgsv.com +wss://nostrv0l.io +wss://relay.nostrula.com +wss://release.nostr.band +wss://nostr.k3tan.com +wss://nostr.bitcoin.social +wss://thesimplekid.space +wss://relay.ypcloud.com +wss://at.nostrwork.at +wss://nostream.dev.kronkltd.net +wss://nostr-relay.xbytez.io +wss://relay.nostr.io +wss://relay.nvote.co:443 +wss://relay.cryptoculture.com +wss://rjj6ejkihilniytxs56qrgtttgcfnnjvbii6vaas6jzppcmekd63ugad.local +wss://puravida.nostr.land.com +wss://bitcoinmaximalist.online +wss://wine.nostr +wss://nostr.messagepush.io +wss://nostrich.love +wss://relaynostrplebs.com +wss://hamstr.to +wss://yosupp.app +wss://snort.social +wss://relay.nostr.gt +wss://nost.ratchat.nl +wss://nostr.chrissmith.site +wss://i.relay.boats +wss://nostr.wineto +wss://nostr.eluc.ch +wss://nostrplebs.com +wss://nostr.tools.global.id +wss://nostr.rocketnode.space +wss://relay.roli.social +wss://bitcoin.nostr.com +wss://relay.badgr.space +wss://nostriches.club +wss://nostr-check.me +wss://nostrelay.nokotaro.com +wss://rbr.bio +wss://rly.bopln.com +wss://6amyhf3sjvgxe5qzbx4xn52pcnqresdmi7szxurp6umkvz6mthxjdcad.onion +wss://6amyhf3sjvgxe5qzbx4xn52pcnqresdmi7szxurp6umkvz6mthxjdcad.local +wss://20nos.lol +wss://test.theglobalpersian.com +wss://nostr.exposed +wss://nostr-pub.liujiale.me +wss://nostream.frank.snowinning.com +wss://nstrs.fly.dev +wss://eospark.com +wss://relay.nosterplebs.com +wss://nproxy.kristapsk.lv +wss://nostr.universalname.space +wss://relays.snort.social +wss://nostr.how +wss://kukks.org +wss://nostr.dutch.cryptonews +wss://relay.cryptojournaal.net +wss://relay.dutch.cryptonews +wss://nostr.cryptojournaal.net +wss://no-str.wnhefei.cn +wss://www.131.me +wss://rsr.uyky.net +wss://nostr-relay.ie9.org +wss://nostr.fmt.wis.biz +wss://nostr.exotr.dev +wss://nostr.merrcurr.com +wss://realy.damus.io +wss://nerostr.xmr.rocks +wss://rkdgwzgvcgrciemlnfsxqgyrv5whgpw44s6zycokmuchpq4ucflgjtqd.local +wss://nostr.simplex.icu +wss://ralay.damus.io +wss://relay.kronkitd.net +wss://nostr.info +wss://nostr.lnnodeinsight.com +wss://nostr.truckenbucks.com +wss://brt.io +wss://nostr.lingoh.dev +wss://relay.nor.st +wss://nostr-relay.inmarkets.com +wss://wiz.biz +wss://nostream.git +wss://nostr.dpbu.de +wss://wcl2meyp236fa3dmfzfyq6aacbdoixrlocb6zozjs6xklxizschj2did.local +wss://relay.nostr.co.jp +wss://relap.orzv.workers.dev +wss://nostr.bitcoiner.socia +wss://eden.nosrt.land +wss://strfry.cryptocartel.social +wss://nostr.citizenry.technology +wss://universe.nostrich.landlang +wss://nostr.inprivate.network +wss://relay.snort.test +wss://lpkue6jtz3pnp7zok4jwlct4n3mzuffsfrpagiffsuplyaswhdtmpoid.local +wss://nostr.rezhajulio.id +wss://lnbits.eldamar.icu +wss://nostr.freefrom.fi +wss://yael.at +wss://rain8128.github.io +wss://badges.page +wss://latam1-nostr.stealthy.co +wss://nostr.relay.se +wss://nfdn.testnet.dotalgo.io +wss://relav.nostr3.io +wss://ofchain.pub +wss://nostr.fmt.wiz.bi +wss://relay.gui.dog +wss://eden.nostry.land +wss://nostr.relay.limo +wss://relay.nostrichs.org +wss://20nostr.mom +wss://relay20damus.io +wss://nostr.sandwich.pro +wss://sq.qemura.xyz +wss://nostr-pub.seminol.dev +wss://nostr.eunundzwanzig.space +wss://nostr.bitcoinet.social +wss://nostr-relay.untether.me +wss://relay.nostr.pub +wss://nostr.100p.org +wss://7tdom3xuus7ekv423ul46w3j43zyixjj54yoe62bndpcrgii3adeppid.local +wss://nostr.gram +wss://nostr.videre.net +wss://nostr-2.zebedee.cloudwss +wss://nostr-pub.wellorder.netwss +wss://sonzai.net +wss://snort.fail +wss://ppavybjpqjft5slnpeovehbegomhwtvvxtesvwwdrfndz6qe2c5kf2ad.local +wss://nostr.land +wss://relay.nvote.com +wss://purevida.nostr.land +wss://nostr.bitcoiner.soical +wss://nostr.inosta.co +wss://nas.lol +wss://wss.nostr.milou +wss://nostr.cheesebot.org +wss://eyeswideshut.ath.cx +wss://test.relays.world +wss://relay.snort.com +wss://relay.nostrplebs.co +wss://nostr.kimi.im +wss://nostrum.com +wss://wss.nostr.wine +wss://relay.usenostr.org +wss://paladium.my:4848 +wss://umbrel.home.local:4848 +wss://nostr.metamadeenah.com +wss://lnbits.sdbtc.org +wss://relay.nostr.mutinywallet.com +wss://relay.theglobalpersian.com +wss://relay1.easymeta.app +wss://relay.nostris.online +wss://s1.wonder3.org +wss://eden.nostraland +wss://byc.klendazu.com +wss://ephemerelay.mostr.pub +wss://7si6co27cvaw5yjyx6asvxfmaw5ah2arywwgrem4y5svi5ntskoeb5id.onion +wss://nwmdev.com +wss://nostro.online +wss://nostr.massimux.com +wss://nostr.glate.ch +wss://nostr.openordex.org +wss://nostr.schorsch.fans +wss://nostr-relay-dev.nisshiee.org +wss://ibz.me +wss://alexandernostrplebs.com +wss://fishbanananostrplebs.com +wss://relay.nostrcheck.com +wss://nostr.roli.io +wss://nostr.net.za +wss://nostr.worldkey.io +wss://nostr-pub.welloorder.net +wss://nostr.actin.io +wss://nostrzebedee.cloud +wss://nostr.zclub.app +wss://nostr.13x.sh +wss://nostr.totient.xyz +wss://nostr1.federated.computer +wss://nostr.zhix.in +wss://nostr.vdstruis.com +wss://caro-relay.fiatjaf.com +wss://nostr.winewss +wss://notstro.wine +wss://nostr.btc-library.com +wss://nostr.phenomenon.space +wss://nostr.octr.dev +wss://nostr.impervious.live +wss://nostr.plebs.space +wss://iefan.tech +wss://w3ird.tech +wss://yunginter.net +wss://nostr.coach +wss://sleepy.cafe +wss://freespeechextremist.com +wss://nostr.uselessshit.com +wss://liberdon.com +wss://eveningzoo.club +wss://gleasonator.com +wss://mindly.social +wss://ottawa.place +wss://noagendasocial.com +wss://poa.st +wss://misskey.io +wss://misskey.cf +wss://nostr.bybieyang.com +wss://mastodon.online +wss://toad.social +wss://best-friends.chat +wss://universeodon.com +wss://mastodon.world +wss://mastodon.social +wss://nostr.vulpem +wss://nicecrew.digital +wss://front-end.social +wss://nostr-relay.wellorder.net +wss://relays.pro +wss://rsslay.sovbit.host +wss://seal.cafe +wss://social.6bq.de +wss://universe.nostrich.landlangjalangzh +wss://social.xenofem.me +wss://mi.hibi-tsumo.com +wss://mstdn.social +wss://mas.to +wss://relay.rebelbase.site +wss://pixelfed.social +wss://digitalcourage.social +wss://ruby.social +wss://cr8r.gg +wss://nijimiss.moe +wss://returtle.com +wss://lor.sh +wss://toot.community +wss://mstdn.jp +wss://relay.berserker.town +wss://nostr.rajabi.ca +wss://fosstodon.org +wss://misskey.takehi.to +wss://izj3isbk3pmade74ontdijodhehsytnw2iokdhh6k3flk4mq2pau6sid.onion +wss://mastodon.scot +wss://nostriches.org +wss://aus.social +wss://relay.nostr.amane.moe +wss://romancelandia.club +wss://stonez.me +wss://merrcurr.com +wss://nostr.ist +wss://onprem.wtf +wss://fedibird.com +wss://s2.wonder3.org +wss://freespech.casa +wss://disabled.social +wss://relay.house +wss://nostr-pub.welllorder.net +wss://social.teamb.space +wss://infosec.exchange +wss://blockedur.mom +wss://progressivecafe.social +wss://mstdn.ca +wss://c.im +wss://mefi.social +wss://basebitcoinplebs.place +wss://med-mastodon.com +wss://ohai.social +wss://defcon.social +wss://pxlmo.com +wss://zlocur7ctbds4qsdswb3qpkp6n2e2ywqne2fp2tdn4fqubpudfiyxwid.local +wss://thebag.social +wss://mstdn.party +wss://relay.serpae.xyz +wss://clew.lol +wss://qoto.org +wss://relay.exchange +wss://nostr.relayable.org +wss://mastodon.coffee +wss://kmy.blue +wss://home.social +wss://detmi.social +wss://brighteon.social +wss://nostr.nom +wss://theblower.au +wss://astral.nostr.land +wss://beige.party +wss://orangepill.dev +wss://redgreenblue.click +wss://nostr.fredix.xyz +wss://nostr.essydns.ca +wss://nostr.private.network +wss://nostr.member.cas +wss://nostr-rely.digitalmob.ro +wss://relay.nostr +wss://relay.current +wss://no-str.or +wss://pl.gamers.exposed +wss://studentchadpolytechnic.com +wss://scicomm.xyz +wss://relay.alien-sos.gov +wss://masto.es +wss://spinster.xyz +wss://parallels-parallels-virtual-platform.local:4848 +wss://relay.daums.io +wss://kolektiva.social +wss://mastodonapp.uk +wss://convo.casa +wss://sfba.social +wss://techhub.social +wss://leafposter.club +wss://nrw.social +wss://mastodon.uno +wss://handon.club +wss://social.vivaldi.net +wss://nostr.goller.net +wss://minazukey.uk +wss://mstdn.beer +wss://expressional.social +wss://paid.nostr.0x50.tech +wss://mstdn.nere9.help +wss://relay.nostr.ai +wss://relay.noswss +wss://relay.nwss +wss://furry.engineer +wss://uselessshit.co +wss://kosmos.social +wss://mynostr.io +wss://premis.one +wss://social.tchncs.de +wss://retro.pizza +wss://pearl-mount-showed-fishing.trycloudflare.com +wss://kpa4k6acxzjv2m2p72keftbpaymwpq2h67jqnin3d4y3djxyheuifoqd.onion +wss://gm7.social +wss://relays.nostr.info +wss://chitter.xyz +wss://hackers.town +wss://anarchism.space +wss://relay.nostrica +wss://halifaxsocial.ca +wss://asimon.org +wss://nostr.blipme.add +wss://troet.cafe +wss://octodon.social +wss://m.cmx.im +wss://filename-ambassador-distance-mountains.trycloudflare.com +wss://nostr.getgle.org +wss://relay.froth.zone +wss://novoa.nagoya +wss://relay.dispute.systems +wss://clubcyberia.co +wss://thechimp.zone +wss://coolsite.win +wss://puravida.nostra.land +wss://shelter.local:1111 +wss://det.social +wss://nostr-2.zebee.cloud +wss://chaosfem.tw +wss://nostr.v01.io +wss://social.kechpaja.com +wss://mastodon.green +wss://plebchain.nostr +wss://plebchain.nostr.land +wss://relay.onsats.org +wss://u5epuanp2fbie4phw6zekzna6zotvsffji4td4ee7iwgwdxlz4kwqqad.onion:5051 +wss://3ddc8bbee6db.ngrok.app +wss://b4968f09859e.ngrok.io +wss://genserver.social +wss://masto.ai +wss://hachyderm.io +wss://shitpost.cloud +wss://oldbytes.space +wss://mastodon.ie +wss://baraag.net +wss://nostr.dvdt.dev +wss://relay.com.de +wss://nostr.rbel.co +wss://bologna.one +wss://nostr-relay.app +wss://kagamisskey.com +wss://nostr-rs-relay.phamthanh.me +wss://nostr.bcmp.com +wss://blogstack.io +wss://rly.nostrkid.com +wss://mastodon.bida.im +wss://freeatlantis.com +wss://pieville.net +wss://climatejustice.rocks +wss://relay.mynostr.id +wss://relay.farscapian +wss://relay.blogstack.io +wss://orwell.fun +wss://misskey.04.si +wss://sushi.ski +wss://nostr.milou.lo +wss://pawoo.net +wss://tooter.social +wss://mastodon.sdf.org +wss://nostr.com +wss://misskey.design +wss://macaw.social +wss://relay.nostr.inforelay.nostr.band +wss://pub1.southflorida.ninja +wss://strawberry-pudding.net +wss://mastodon-japan.net +wss://nostream.0x50.tech +wss://multiplextr.coracle.social +wss://pleroma.skyshanty.xyz +wss://uxxq6b2enojvflhkrzsg4erakd5rrb7v2cql4m4pspj4xtqwyli47rid.local +wss://masto.deoan.org +wss://replay.damus.io +wss://melhorque.com.br +wss://nostr1.actn.io +wss://nostr.bolt.fun +wss://relay.vtbmoyu.com +wss://eosla.comrelay.zeh.app +wss://bikeshed.party +wss://nostr.xanny.family +wss://milker.cafe +wss://nostr.give.africa +wss://relay.got-relayed.com +wss://cum.salon +wss://puravid.nostr.land +wss://soc.punktrash.club +wss://seafoam.space +wss://h4.io +wss://nostr.swiss.enigma.ch +wss://toot.cafe +wss://sneed.social +wss://newsie.social +wss://indg.club +wss://xoxo.zone +wss://relay.nostr.bitcoiner.social +wss://relay.snort.band +wss://socel.net +wss://social.coop +wss://postpandemicparty.org +wss://merveilles.town +wss://search.nostr.wine +wss://search.nos.today +wss://mstdn-huahin.com +wss://eden.nostr.la +wss://nostrja-kari-nip50.heguro.com +wss://relay.gems.xyz +wss://plnetwork.xyz +wss://nostr.swiss-enigma.sh +wss://geofront.rocks +wss://toot.io +wss://indieweb.social +wss://mastodon.content.town +wss://awaymessage.club +wss://relay.intify.io +wss://mstdn.science +wss://maniakey.com +wss://otadon.com +wss://mstdn.guru +wss://misskey.noellabo.jp +wss://hessen.social +wss://mastodon.top +wss://ipv6.nostr.wirednet.jp +wss://relay.nostr-relay.org +wss://cum.camp +wss://nebbia.fail +wss://current.fyi +wss://bae.st +wss://lolison.network +wss://ioc.exchange +wss://bylines.social +wss://decayable.ink +wss://nostr.unitedserializer.com +wss://urbanists.social +wss://dark-elves.social +wss://writing.exchange +wss://nostr.rikmeijer.nl +wss://misskey.social +wss://ht.nixre.net +wss://mathstodon.xyz +wss://t7jvqwu35hneszx7fihsprbcpwonlcfnsjr4xtn6shqgwbv324w4gdid.local +wss://abla.news +wss://muenchen.social +wss://homeserver.drake-carp.ts.net:4848 +wss://snailedit.social +wss://mastodon.gamedev.place +wss://tech.lgbt +wss://mast.lat +wss://econtwitter.net +wss://veganism.social +wss://btclolap6mm4tl37huslk6j76enq7qxaj2kwq7w6cdr5ros56eletcqd.onion +wss://toot.cat +wss://nostr.zenon.info +wss://misskey.art +wss://nostr.hushvault.ie:4848 +wss://o6ga6lxnax2z7pgkkenifollohkrv55r36mdzdtofi7d5yyif2f4o5yd.onion:5051 +wss://20nostr-pub.wellorder.net +wss://ecoevo.social +wss://nostr.zerofiat.world +wss://nostr.atitlan.io +wss://detroitriotcity.com +wss://rollenspiel.social +wss://paquita.masto.host +wss://literatur.social +wss://dave.st.germa.in +wss://sunny.garden +wss://nostrpro.xyz +wss://relay.getalby.com +wss://misskey.systems +wss://mamot.fr +wss://social.anoxinon.de +wss://relay.nostr.social +wss://mindmachine.org +wss://microblog.club +wss://relay.utxo.com +wss://universe.nostrich.landlangth +wss://social.teci.world +wss://social.freetalklive.com +wss://mk.absturztau.be +wss://social.ornella.xyz +wss://left-tusk.com +wss://bofh.social +wss://a11y.social +wss://shroomslab.net +wss://relay2.nostr.vet +wss://mugicha.club +wss://laserbeak.local:4848 +wss://annihilation.social +wss://humble.cafe +wss://nostr.relay.damus.io +wss://nostr.milol.lol +wss://nostr.blimpme.app +wss://noc.social +wss://wetdry.world +wss://nostr.taxi +wss://nostr.21-bitcoin.org +wss://relay.llevotu-bitcoiners.info +wss://pl.kitsunemimi.club +wss://djsumdog.com +wss://citadel.local:4848 +wss://federate.blogpocket.com +wss://chaos.social +wss://mastodon.me.uk +wss://oxtr.dev +wss://misskey.cloud +wss://varishangout.net +wss://lacosanostr.com +wss://willem.currycash.net:4848 +wss://lightniningrelay.com +wss://queer.party +wss://lightning.relay.com +wss://mastodon.nl +wss://purplepag.es +wss://cupoftea.social +wss://bitcoiner.socialwss +wss://relay.current.io +wss://sigmoid.social +wss://wandering.shop +wss://braydmedia.de +wss://quey.la +wss://nostr.zebeedee.cloud +wss://nostr-2.zebeedee.cloud +wss://artsio.com +wss://nostr1676031941328.app.runonflux.io +wss://arsip.ddns.net +wss://relay.nostrcitadel.org +wss://relay.nostr-citadel.org +wss://nostr-citadel.org +wss://blastr.f7z.io +wss://nostr.myowndamnnode.com +wss://snowdin.town +wss://nostr.zxcvbn.space +wss://relay.notmandatory.org +wss://bitcoin.social +wss://friendsofdesoto.social +wss://zirk.us +wss://digipres.club +wss://nostr-test.elastos.io +wss://nostr.onsat.org +wss://damus.relay.io +wss://beefyboys.win +wss://nostrija-kari.heguro.com +wss://froth.zone +wss://ns.penseer.com +wss://relay.nostrical.com +wss://nostr.hoshizora.ch +wss://kunigaku.github.io +wss://nostr.shino3.net +wss://umbrel.tailbb128.ts.net:4848 +wss://tailbb128.ts.net:4848 +wss://fedi.twoshortplanks.com +wss://filter.nostr.winebroadcasttrue +wss://nostr.doufu-tech.com +wss://relay.hodl.haus +wss://pgh.social +wss://coeditor-congested.fractalnetworks.co +wss://mastodon.podaboutli.st +wss://frenfiverse.net +wss://nostr.f4255529.fun +wss://nostream.megadope.snowinning +wss://kiritan.work +wss://forever21.lol +wss://zitron.net +wss://mastodon-swiss.org +wss://relay1.current.fyi +wss://nostr.plebs.com +wss://multiplextr.corocal.social +wss://nostr.zedebee.cloud +wss://nostr.mutinywallet.comisntbanned.addthatonesotheycangetnotesrelayedtotherestofthenetworkfrominsideofchina +wss://mastodon.mit.edu +wss://relaynostrati.com +wss://relaynostr.band +wss://relaynostr.info +wss://relaychenxixian.cn +wss://relaysnort.social +wss://relaynostr.com.au +wss://nostr-tbd.website +wss://relay.hoshizora.ch +wss://social.balsillie.net +wss://strangeobject.space +wss://relay.nvote20.co +wss://fla.red +wss://urusai.social +wss://chad.polytechnic.com +wss://robo358.com +wss://conxole.io +wss://relay.ohbe.me +wss://nostr.bitcoiner.com +wss://www.mutinywallet.com +wss://x.9600.link:10070 +wss://a11y.info +wss://homelab.host +wss://k65qz57zx4sw24fow2bvchjgmpjqljj4cp3oa7dtpbbprjifsdotggid.local +wss://relay.vtuber.directory +wss://x.9600.link:8000 +wss://nostr.plebs.win +wss://renkontu.com +wss://nost.massmux.com +wss://submarin.online +wss://social.librem.one +wss://relai.kongerik.et +wss://mstdn.io +wss://astral.swiss-enigma.ch +wss://stereophonic.space +wss://relayer.pleb.social +wss://mastodon.nz +wss://nostr.nostr.de +wss://nostr.thezap.club +wss://proxy.shroomslab.net +wss://pfr24mrpxowclhm4y6adu36kbo3erx7gskzyfqqhnhfpdkdmylpfgkyd.local +wss://nostr.hodl.haus +wss://vtdon.com +wss://relay.nost.band +wss://fnxwipsg3lfzij64lvjgmutvkkpd7eo2mr2khxkofyywf3vsvbk73jad.onion +wss://fnxwipsg3lfzij64lvjgmutvkkpd7eo2mr2khxkofyywf3vsvbk73jad.local +wss://md.hugo.nostr +wss://boks.moe +wss://truthsocial.co.in +wss://mastodon.xyz +wss://relay.universalname.space +wss://relay-nostr.wirednet.jp +wss://fedi.pawlicker.com +wss://favcalc.com +wss://rot13maxi.com +wss://awayuki.net +wss://mazinkhoury.com +wss://g0v.social +wss://shpposter.club +wss://a.lufimianet.jp +wss://pura20vida.nostr.land +wss://sotalive.net +wss://sendsats.lol +wss://vida.page +wss://wagvwfrdrikrqzp7h3b5lwl6btyuttu7mqpeji35ljzq36ovzgjhsfqd.onion +wss://uec2cmjauzufrtlq6wq6l2ujfncdvo3suezz423gsvz5xvhehm2mcgid.onion +wss://mastodon-belgium.be +wss://rejecttheframe.xyz +wss://nogood.store +wss://getaiby.com +wss://plebchain.club +wss://nostrverified.com +wss://x.9600.link +wss://n0p0.shroomslab.net +wss://orangemakura.xyz +wss://jamw.net +wss://7ab7qqbj2dw3pjnkoskgsfn4ikqc7orwnkpmcfjmeobw63kf4zgykjid.local +wss://snabelen.no +wss://floss.social +wss://mastoot.fr +wss://kmc-nostr.amiunderwater.com +wss://hodl.camp +wss://xmr.usenostr.com +wss://nostr-pub.wellorn.net +wss://gudako.net +wss://ryona.agency +wss://kafeneio.social +wss://massmux.com +wss://freezepeach.online +wss://nostream-production-f83d.up.railway.app +wss://taobox.pub +wss://mastodon.radio +wss://einundzwanzig.relay.com +wss://journa.host +wss://social.here.blue +wss://toot.wales +wss://staging.nostr.com.se +wss://pleroma.soykaf.com +wss://berserker.town +wss://zapforart.site +wss://forall.social +wss://nostrja-kari.heguro.comyee +wss://higheredweb.social +wss://social.gnuhacker.org +wss://the.hodl.haus +wss://ng4jk6yiqgfczo4wyxszuj7w6jok3fptehu533o3mlzs3vph3dvjfdid.onion +wss://nostr.mtpx.ovh +wss://relay.coollamer.com +wss://glasgow.social +wss://fedi.absturztau.be +wss://nostr.frostr.xyz +wss://relay.runningnostr.lol +wss://relay2.vtuber.directory +wss://nerdculture.de +wss://nostr-relay.untethr.meoperator +wss://nostr.verif-slothy.win +wss://nostr.pub +wss://social.process-one.net +wss://invillage-outvillage.com +wss://otofu.uk +wss://universe.nostrich.landlangenlangpt +wss://mastodon.iriseden.eu +wss://frighteningdeafeningagent.nailuogg.repl.co +wss://obo.sh +wss://relay.hodlhaus.net +wss://rdrama.cc +wss://nostr.packetlostandfound.us +wss://test.relay +wss://mastodonbooks.net +wss://southflorida.ninja +wss://blob.cat +wss://tooting.ch +wss://relay.pineapple.pizza +wss://relay.nostr.directory +wss://relar.nostr.bg +wss://reisen.church +wss://relay.honk.pub +wss://nostr.rsfriedl.com +wss://relay.nort.social +wss://1611.social +wss://the.hodl.house +wss://relayable.com +wss://fedi.syspxl.xyz +wss://nostrpub.yeghro.site +wss://skr5bbrgzfnideglw4cs2iw6au2jm2b7gupocxbmkv5qopo6rcitmiqd.local +wss://a2mi.social +wss://status.relayable.org +wss://toot.blue +wss://btcqspp5dl4rlgl5pomcyv3odfeki7a5zrmjoekyu5vsoqz5bth4e7yd.onion +wss://bitcoinr6de5lkvx4tpwdmzrdfdpla5sya2afwpcabjup2xpi5dulbad.onion +wss://7tdom3xuus7ekv423ul46w3j43zyixjj54yoe62bndpcrgii3adeppid.onion +wss://dnze4ekho2kuiejwatjw5omeprtmdaum2ukok52roiu5rztii3rp2aid.onion +wss://53snncs7vegargpaardbxjnii2oan3xpmbeaf6czwoqa2axz5mvbsjid.onion +wss://fnqdhz3df33da6wxg7jskvumd5rjn3nknln6ecun7uwwysc7vkwkjgid.onion +wss://relay.thefockinfury.wtf +wss://silliness.observer +wss://freak.university +wss://piaille.fr +wss://nostr.weking.tk +wss://f.reun.de +wss://radixrat.com +wss://hostux.social +wss://chat.freenode.net +wss://no.str..cr +wss://nostr.inoata.cc +wss://kappa.seijin.jp +wss://floyds.io +wss://mast.dragon-fly.club +wss://zbd.ai +wss://misc.name +wss://pdx.social +wss://0w0.is +wss://d6jvu2tev2rblkuzgu4ydw2413jizr53j26ut47hxpykvtusbvekhiid.onion +wss://d6jvu2tcv2rblkuzgu4ydw24l3jizr53j26ut47hxpykvtusbvekhiid.onion +wss://drcassone.social +wss://mastodon.energy +wss://rayci.st +wss://ischool.social +wss://nostr.halfway2forever.com +wss://relay.nostr.rocks +wss://nostr.stoner.com +wss://2nodez.com +wss://rs.nostr-x.com +wss://schleuss.online +wss://thrashzone.org +wss://relay.nostrgraph.com +wss://relay.2nodez.com +wss://occult-zuki.com +wss://mastodon.lithium03.info +wss://bigbadpc.local:4848 +wss://social.gr0k.net +wss://nostr.wirednet.jp +wss://relay.plebster.com +wss://bitcoin.nostr +wss://mastodon.im +wss://brb..io +wss://nostr.sandwhich.farm +wss://relay.nos.lol +wss://beta.nostr.v0l.io +wss://eden.nostr.space +wss://powerlay.xyz +wss://rap.social +wss://relay.mutinywallet.com +wss://rogue.earth +wss://600.wtf +wss://klabo.blog +wss://petrikajander.com +wss://tgkzmdd.help +wss://nostr.red +wss://brb.lol +wss://jz2l2bf6f6wssdqwkg7ogthkc5i3ymyiwkaz3tbhff6ro3h3zqddekyd.onion +wss://mstdn.mini4wd-engineer.com +wss://nostr.exposd +wss://nostr.a-ef.org +wss://computerfairi.es +wss://social.targaryen.house +wss://o3o.ca +wss://walkah.social +wss://cosocial.ca +wss://filter.nostr.band +wss://relais.nostrview.com +wss://gigaohm.bio +wss://dobbs.town +wss://bark.lgbt +wss://mastodon.gal +wss://snug.moe +wss://genomic.social +wss://relay.orangepilldev.com +wss://social.sdf.org +wss://social.camph.net +wss://mstdn.poyo.me +wss://nein.lol +wss://nostr.i00.org +wss://kemono.ink +wss://mu.zaitcev.nu +wss://libera.site +wss://ca.hibi-tsumo.com +wss://social.bund.de +wss://xscape.top +wss://social.lol +wss://birds.town +wss://arnostr.com +wss://nostr.33co.de +wss://relay.nostr.lighting +wss://metadata-contacts-relays.pages.dev +wss://webs.node9.org +wss://pleroma.elementality.org +wss://suya.place +wss://livellosegreto.it +wss://peoplemaking.games +wss://nattois.life +wss://typo.social +wss://neutrine.com +wss://ragner-relay.com +wss://wss.nostr.uselessshit.co +wss://wss.nostrue.com +wss://relay.nvote.co:433 +wss://disobey.net +wss://rneetup.com +wss://arnostr.com:8433 +wss://relay.uxto.one +wss://relay.hackerman.pro +wss://thisis.mylegendary.quest +wss://poliversity.it +wss://sats.lnaddy.com +wss://rs2.abaiba.top +wss://rs1.abaiba.top +wss://rs2.abaiba.top.abaiba.top +wss://social.matarillo.com +wss://nostr01.counterclockwise.io +wss://backup.local:4848 +wss://touhou.vodka +wss://mi-wo.site +wss://nostr.f7z.io +wss://alive.bar +wss://strfry.nostr-x.com +wss://mastodontti.fi +wss://nostr.wellorder.net +wss://y.9600.link:8000 +wss://ephemrelay.mostr.pub +wss://byc-italia.online +wss://fissionator.com +wss://stranger.social +wss://eupolicy.social +wss://nostr-desktop.local:4848 +wss://aoir.social +wss://mstdn.plus +wss://nostrproxy.io:3333 +wss://mastodon.hams.social +wss://jorts.horse +wss://metalhead.club +wss://dice.camp +wss://mstdn.y-zu.org +wss://loffchain.pub +wss://mastodon.llarian.net +wss://nostr-relay2.thefockinfury.wtf +wss://2g2jzcfgq5lcrceuq23lmya2drm3ku5qmqimr3bvu3amol55vidctrad.onion +wss://mastodon.au +wss://bgme.me +wss://nostr.badran.xyz +wss://nostr.coincreek.com +wss://nostream-test.up.railway.app +wss://relay.blackthunder.click +wss://relay.grorp.com +wss://atomicpoet.org +wss://iddqd.social +wss://gusto.masto.host +wss://lifehack.social +wss://blorbo.social +wss://freecumextremist.com +wss://13bells.com +wss://rsslay.nostr.netrelay +wss://relay.zerosequioso.com +wss://nauka-relay.herokuapp.com +wss://nostr.nofdeofsven.com +wss://out.of.milk +wss://cawfee.club +wss://r.relay.fan +wss://alo.ottonove891.cf +wss://keinoha.tailnet-0240.ts.net +wss://nostr.paralelnipolis.cz +wss://tuiter.rocks +wss://elizur.me +wss://nostr-dev.newstr.io +wss://discuss.systems +wss://blahaj.zone +wss://mastodon.art +wss://makersocial.online +wss://gamepad.club +wss://nostr.flameofsoul.ru +wss://dmv.community +wss://relay.nostr.com +wss://nostr-desktop.saiga-shark.ts.net:4848 +wss://nostrfoxden.ddns.net:4848 +wss://soc.umrath.net +wss://ravenation.club +wss://oisaur.com +wss://nostr-relay.net +wss://social.mikutter.hachune.net +wss://lewacki.space +wss://fediscience.org +wss://todon.eu +wss://nuccy-nuc7i5bnk.local:4848 +wss://games.gamertron.net:4848 +wss://fiedlerfamily.net +wss://postnstuffds.lol +wss://nostr.planetary.social +wss://worldkey.io +wss://hcommons.social +wss://gymp7qquljs47xbbvs47hkptnyyzegy2jkst26mkjxaciifqffjatqid.onion +wss://rs3.abaiba.top +wss://sudo-nostr.com +wss://satgag.site +wss://nostr.lnbitcoin.cz +wss://relay20nostrplebs.com +wss://2pbkpndvpeebljfvjew6auq63lndzszqnntct5aqfmazslerzxe75kad.onion +wss://dragonchat.org +wss://welcome.nostr.wine +wss://relay.nostr.land +wss://social.linux.pizza +wss://dnppj4kopczovvzvpzmihv2iwe5wt3gbrxjnltjc2zdjpttrdz4owpad.onion +wss://potofu.me +wss://nostrbr.online +wss://mastorol.es +wss://notebook.taild34d0.ts.net +wss://t.aqn.jp +wss://nostr.mutinywallet +wss://wcone.nostr.wine +wss://norden.social +wss://eostagram.com +wss://shigusegubu.club +wss://toot.jkiviluoto.fi +wss://kiwifarms.cc +wss://swiss-talk.net +wss://v532btfg2fb4za2g476a7w23pgpkllc7uq274wqtktwjogt5ynb3ukqd.local +wss://mstdn.maud.io +wss://arc1.arcadelabs.com +wss://nostr.jp +wss://relay-jp.nostr.wirrdnet.jp +wss://climatejustice.social +wss://witter.cz +wss://mastodon.pnpde.social +wss://ttrpg-hangout.social +wss://beehaw.org +wss://thecanadian.social +wss://nostr.fbxl.net +wss://relay.sandwich.farm +wss://nostr.olwe.link +wss://botsin.space +wss://zeroes.ca +wss://photog.social +wss://paid.nostr.lc +wss://free.nostr.lc +wss://test.nostr.lc +wss://gzanlkgurj7zd3psqms3da4vrw4imurnyyzaycfuiiug7elqow7xlayd.onion:5051 +wss://masto.nu +wss://mastodon.uy +wss://bit.relay.center +wss://offchain.relay.center +wss://damus.relay.center +wss://wine.relay.center +wss://eden.relay.center +wss://moth.social +wss://nostr.masmux.com +wss://chrome.pl +wss://mastodon.ktachibana.party +wss://ak.kawen.space +wss://mementomori.social +wss://relay.s3x.social +wss://lnbits.michaelantonfischer.com +wss://yof23ggqmert72c5wcl5qglphapy3o2xjdedtkbrn2dt5rbae2s7f6qd.onion +wss://relay.snort.socail +wss://post.lurk.org +wss://yiff.life +wss://q3zaylwjjhq77yzx34lbydz26szzjljberwetkjgxgsapcekrpjzsmqd.onion +wss://lnbits.b1tco1n.org +wss://welcome.nostr.relay +wss://sound-money-relay.denizenid.com +wss://carnivore-diet-relay.denizenid.com +wss://africa.nostr.joburg +wss://nostr.jolt.run +wss://nostr.chainbits.co.uk +wss://ithurtswhenip.ee +wss://nostr.cloudversia.com +wss://relay1.east.us.nostr.btron.io +wss://ca.orangepill.dev +wss://pdx.land +wss://linh.social +wss://okla.social +wss://androiddev.social +wss://spore.social +wss://mastodo.fi +wss://kabedon.space +wss://nost.inosta.cc +wss://relay2cdamus.io +wss://nostr.openhoofd.nl +wss://dragonscave.space +wss://genart.social +wss://dewp.space +wss://layer8.space +wss://qou7zzll2mxx2ehl73n6pptmhizl5b3entowljlin3sqhcvltxdtlmad.onion:5051 +wss://nostr.wines +wss://relay.snort.relay.ryzizub.com +wss://pixelfed.de +wss://nostr.holyscapegoat.com +wss://nostr.einunzwanzig.space +wss://nostr.hifish.org +wss://colearn.social +wss://topspicy.social +wss://mastodon.neat.computer +wss://relay.nostr.hach.re +wss://nostr.dakukitsune.ca +wss://7ab7qqbj2dw3pjnkoskgsfn4ikqc7orwnkpmcfjmeobw63kf4zgykjid.onion +wss://esq.social +wss://famichiki.jp +wss://tribe.net +wss://masto.nobigtech.es +wss://umbrel-nuc.local:4848 +wss://mastodon.cocoasamurai.social +wss://debian.taildd32b.ts.net:4848 +wss://vlt.ge +wss://relay.johnnyasantos.com +wss://snort.relay.center +wss://nb.relay.center +wss://waag.social +wss://concentrical.com +wss://stat.rocks +wss://oransns.com +wss://relay-jpp.nostr.wirednet.jp +wss://oc.todon.fr +wss://jundow.gitlab.io +wss://neurodifferent.me +wss://jazztodon.com +wss://nostrich.friendship +wss://indieauthors.social +wss://werunbtc.com +wss://frogtalk.lol +wss://pop-os.local:4848 +wss://fediver.de +wss://d6qxo55dhms6revgrmbindvb5ejd3gw5hrji7ylkm6khghii3hjs3uyd.onion +wss://eldritch.cafe +wss://karlsruhe-social.de +wss://social.yl.ms +wss://nostr.mycloudhouse.duckdns.org +wss://nostr.otc.sh +wss://nya.social +wss://relay2.nostrchat.io +wss://relay1.nostrchat.io +wss://nostrja-world-relays-test.heguro.com +wss://ndk-relay.local +wss://reespeech.casa +wss://pleroma.atyh.cc +wss://lawfedi.blue +wss://akkoma.jasminetea.uk +wss://peeledoffmy.skin +wss://plush.city +wss://astrodon.social +wss://samenet.social +wss://toot.bike +wss://mi.yukioke.com +wss://social.growyourown.services +wss://mastodon.nu +wss://lou.lt +wss://functional.cafe +wss://relaydamus.io +wss://coma.social +wss://social.fbxl.net +wss://biplus.social +wss://toots.matapacos.dog +wss://psychoet.ml:3250 +wss://danserver.equipment +wss://nostr.lacrypta.com.ar +wss://wonkodon.com +wss://nostr.seankibler.com +wss://autistics.life +wss://cambrian.social +wss://rvqkqr5kl3dvvxyn67rfowcnvoflx4zby5tjbysavym4ycckti4dbjyd.onion +wss://swiss.nostr.lc +wss://snac.saifulh.online +wss://loma.ml +wss://nostr.privoxy.io +wss://366.koyomi.online +wss://mastodon.stormy178.com +wss://podcastindex.social +wss://bitcoiner.socia +wss://fedi.ml +wss://replayable.org +wss://nostr.schroomslab.net +wss://nostr.global.fans +wss://relay.weedstr.net +wss://vocalodon.net +wss://relay.nostr.bandadd +wss://nostr.oxtr.devadd +wss://jarvis.taild68e2.ts.net:4848 +wss://t7jvqwu35hneszx7fihsprbcpwonlcfnsjr4xtn6shqgwbv324w4gdid.onion +wss://stonez.local:4848 +wss://notrustverify.ch +wss://woof.group +wss://mastodon.floe.earth +wss://lnbits.plebtag.com +wss://kpop.social +wss://relay.wavlake.com +wss://mastodon.sharma.io +wss://travelpandas.fr +wss://alphapanda.prowss +wss://relay.saes.io +wss://barelysocial.org +wss://masto.komintern.work +wss://norcal.social +wss://nostr.zbd.gg +wss://mk.outv.im +wss://mstdn.mx +wss://col.social +wss://nostr.freedom.fi +wss://filter.nostr.winebroadcasttrueglobalall +wss://eden.nostr.landv +wss://me.dm +wss://emacs.ch +wss://winonostr.wine +wss://infoplebstr.com +wss://mas.towss +wss://gruene.social +wss://relay.freeplace.nl +wss://itis.to +wss://bsky.social +wss://lnbits.thefockinfury.wtf +wss://5xxkt7zvmh4zdsjw64lgvchjdlrrgw4w2huujiiud35qms6gnkn5azad.onion +wss://eientei.org +wss://artisan.chat +wss://nustr.mom +wss://relay.nostrhraph.net +wss://shitposter.club +wss://nostril.cam +wss://nostr.spaceshell.xyz +wss://relay.wtr.app +wss://tdd.social +wss://d3meec25b53kegrnjmtmtyynikbkmuxf4jqgtk3sonjs6e62hpaezyqd.onion +wss://tkz.one +wss://freerelay.xyz +wss://nfdn.betanet.dotalgo.io +wss://mitra.social +wss://hablanews.io +wss://calle.wtf +wss://voskey.icalo.net +wss://nostr.sloyhy.win +wss://framapiaf.org +wss://nostrnodeofsven.com +wss://ciberlandia.pt +wss://gnusocial.net +wss://relay.s3x.socia +wss://paste.2nodez.com +wss://union.place +wss://bofh.socia +wss://sovbit.dev +wss://lnbits.btc-payserver.eu +wss://osna.social +wss://im-in.space +wss://junxingwang.org +wss://relay.nostr.wirednet.jpcheck +wss://libranet.de +wss://fedisnap.com +wss://woodpecker.social +wss://gensokyo.town +wss://social.imirhil.fr +wss://links.potsda.mn +wss://law-and-politics.online +wss://nostrich.bar +wss://tweesecake.social +wss://nostr.kisiel.net.pl +wss://relay.kisiel.net.pl +wss://calckey.social +wss://nostr.cercatrowa.me +wss://meganekeesu.tokyo +wss://lndiscs.duckdns.org +wss://omochi.xyz +wss://wue.social +wss://nostr.libreleaf.com +wss://rusnak.io +wss://rsslay-production.up.railway.app +wss://nostr.yuhr.org +wss://bod4ojj37fneith2setv3qjbii563wesbjqgdipdz4ag6voic2xk5iad.onion +wss://fediverse.blog +wss://pouet.chapril.org +wss://baq5ufl2rnczpalnoqabxwpjm3kvhzduwgvptxzx7yq37oqdbgf65syd.local +wss://baq5ufl2rnczpalnoqabxwpjm3kvhzduwgvptxzx7yq37oqdbgf65syd.onion +wss://cryptodon.lol +wss://nostr.debancariser.com +wss://mizunashi.hostdon.ne.jp +wss://relay.deezy.io +wss://bbq.snoot.com +wss://historians.social +wss://mi.mashiro.site +wss://mastodonpost.social +wss://nostrpub.welliorder.net +wss://social.cologne +wss://metapixl.com +wss://wandzeitung.xyz +wss://lightninhrelay.com +wss://techopolis.social +wss://lnbits.btcpins.com +wss://purplenostrich.com +wss://onewilshire.la +wss://sself.co +wss://anygemini13.blogs.sapo.pt +wss://federated.press +wss://metadata.nostr.com +wss://nostr.hodl.ar +wss://goreslut.xyz +wss://mastodon.com.tr +wss://climatejustice.global +wss://brotka.st +wss://sueden.social +wss://mstdn.fr +wss://abid.cc +wss://lnbits.fuckedbitcoin.com +wss://meow.social +wss://nostr.rehab +wss://mstdn.media +wss://nodeo1.nostress.cc +wss://nostrmassmux.com +wss://sauropods.win +wss://civilians.social +wss://pnw.zone +wss://zebeedee.cloud +wss://nostr.walletofsatishi.com +wss://aufovmqaxj5nhqmtorhgpogdjxefhkff25cbyyjt2sub3vwg6b6rplid.onion +wss://forfuture.social +wss://76f67qcwxsxpz7cfozlzunota2ejqznpldc5pnqtyq233hjpjzrmlfid.local +wss://toot.garden +wss://umbrel.tail9dfb.ts.net:4848 +wss://mastodon.chasem.dev +wss://misskey.pm +wss://merovingian.club +wss://chirp.enworld.org +wss://paid.no.str.ce +wss://masto.bike +wss://masto.1146.nohost.me +wss://eosla.comno-str.orgrelay.zeh.appno-str.orgrelay.zeh.app +wss://nixnet.social +wss://pay.zapit.live +wss://respublicae.eu +wss://nekomiya.net +wss://mastodon.internet-czas-dzialac.pl +wss://plushies.social +wss://lnb.openchain.fr +wss://mastodon.lol +wss://social.rebellion.global +wss://ruhr.social +wss://mi.farland.world +wss://pkutalk.com +wss://systemli.social +wss://nostr.minimue81.selfhost.com +wss://mastodon.la +wss://everything.happens.horse +wss://pagan.plus +wss://clacks.link +wss://u-tokyo.social +wss://fediverse.projectftm.com +wss://mastodon.bachgau.social +wss://social.kabi.tk +wss://ty3zdjkwlxo4zah6tgdoolznjcbvkhxpcvjyqe2buxeg23hbeyvr3rad.local +wss://pleroma.wakuwakup.net +wss://social.horrorhub.club +wss://nostr.nightowlstudios.ca +wss://mastodon.ml +wss://relay.orangepillapp.com +wss://misskey.sup39.dev +wss://mfmf.club +wss://pokemon.mastportal.info +wss://gohan-oisii.net +wss://aipi.social +wss://nostr.semisol.com +wss://oslo.town +wss://relay.layer.systems +wss://naharia.net +wss://social.elbespace.de +wss://linuxrocks.online +wss://b81m3pf94ridtry53g8ufyyrjtjaoxgbyjbs5k8qrqkr1whocxiy.loki:8080 +wss://lvl01.tater.ninja +wss://kinky.business +wss://relay.bitblockboom.com +wss://fedi.omada.cafe +wss://social.secret-wg.org +wss://celebrity.social +wss://weirdo.network +wss://mastodon.design +wss://berlin.social +wss://misskey.yukineko.me +wss://mindmachine.688.org +wss://sackheads.social +wss://a.farook.org +wss://social.ridetrans.it +wss://nostr-2.crypticthreadz.com +wss://test.itas.li +wss://fashionsocial.host +wss://ordinary.cafe +wss://social.arinbasu.online +wss://nostr.crypticthreadz.com +wss://relay.zebedee.cloud +wss://nostr.pub.wellorder.net +wss://09d4-5-161-189-144.ngrok-free.app +wss://andalucia.social +wss://udongein.xyz +wss://squeet.me +wss://mastodon.org.uk +wss://guild.pmdcollab.org +wss://relay.fi +wss://black.nostrscity.club +wss://relay.darker.to +wss://denostr.paiya.app +wss://noste.lu.ke +wss://wss.node01.nostress.cc +wss://theverge.space +wss://swiss.social +wss://relay.webstr.org +wss://nostr.shsbt.xyz +wss://relay.nostr.mom +wss://bitcoinmaximlaists.online +wss://relay.openhoofd.nl +wss://toot.aquilenet.fr +wss://toot.ale.gd +wss://relay.devstr.org +wss://lounge.town +wss://amala.schwartzwelt.xyz +wss://planetasieve.com.br +wss://alcrypt.ru:20911 +wss://webzero.grin.plus:8080 +wss://pylons.lightlns.com:28556 +wss://etourneau.fr:28343 +wss://sentie.relay.rts.network +wss://hermes.boarstudios.com +wss://non-central.pw +wss://nostrum.casa +wss://press.coop +wss://neovibe.app +wss://mstdn.starnix.network +wss://nostr.ameristraliagov.com +wss://cryptodon.chat +wss://umbrell.local:4848 +wss://mastodon.codingfield.com +wss://fe.disroot.org +wss://national.catposting.agency +wss://mastodon.pinewoodroad.net +wss://podcasts.social +wss://20nostr.semisol.dev +wss://nostr.oxtr.net +wss://mstdn.o-nature-culture.net +wss://dearcoati6.lnbits.com +wss://die-partei.social +wss://donotban.com +wss://creative.ai +wss://metaskey.net +wss://spacey.space +wss://node01.nostreess.cc +wss://node01.nostress.co +wss://niscii.xyz +wss://3gkpphcfwb6w5iq6axnmlbvr7pz2t37uy4ofocyijzttzrbz4jy43fid.onion +wss://3gkpphcfwb6w5iq6axnmlbvr7pz2t37uy4ofocyijzttzrbz4jy43fid.local +wss://sportsbots.xyz +wss://videos.lukesmith.xyz +wss://nostr.btcfreedom.ca +wss://gardenstate.social +wss://bg-btc.local:4848 +wss://0fa53e299287.ngrok.app +wss://bird.makeup +wss://nlayer.lbdev.fun +wss://relay.queiroz.vip +wss://mstdn.business +wss://osage.moe +wss://botrelay.com +wss://filter.wine +wss://gingadon.com +wss://noncentral.pw +wss://honi.club +wss://xn--baw-joa.social +wss://nostr.semisol.devwss +wss://relay.iris.to +wss://mynostrrelay.deno.dev +wss://social.heise.de +wss://vavursybkbgfyow7nnst5jnqsj2xyteusf3zeerbjdizq6y7h25v4syd.onion:5051 +wss://vavursybkbgfyow7nnst5jnqsj2xyteusf3zeerbjdizq6y7h25v4syd.onion:5050 +wss://relao.nostr.bg +wss://phpc.social +wss://mastodon.kylerank.in +wss://gameliberty.club +wss://rot.gives +wss://www.nostrweb.xyz +wss://ligma.pro +wss://mastodon.grin.hu +wss://geeknews.chat +wss://devdilettante.com +wss://relay.nosr-latam.link +wss://nosr.bitcoiner.social +wss://raru.re +wss://create-key.net +wss://lgbtqia.space +wss://fluffy.family +wss://mas.town +wss://bird.froth.zone +wss://akkoma.cryptoschizo.club +wss://relay.ramus.io +wss://40two.site +wss://relay.40two.site +wss://vis.social +wss://mk.paritybit.ca +wss://nyan.network +wss://ln.weedstr.net +wss://wikis.world +wss://social.fringe.com +wss://umbraxenu.no-ip.biz +wss://heads.social +wss://tsqdakwo4dh5ej3llsi52ftxfbialteu3jm4cmvxaksl3psbyeoyxxqd.onion +wss://jameliris.to +wss://mastodon.thirring.org +wss://miniwa.moe +wss://welcom.nostr.wine +wss://nostr.vulpem.comwss +wss://relay.semisol.dev +wss://kitsunes.club +wss://songbird.cloud +wss://nostr.wyssblitz.org +wss://libera.tokyo +wss://trpger.us +wss://comam.es +wss://nostr-pub.semisol.devaddittoyourrel +wss://social.dev-wiki.de +wss://ostfrie.se +wss://darmstadt.social +wss://nostr.cx.ms +wss://alentours.cc +wss://nostr.kleofash.eu +wss://gib.social +wss://test23.hifish.org +wss://rapemeat.solutions +wss://filter.stealth.winebroadcasttrue +wss://nostr.montre +wss://grumble.social +wss://nostr.roli.social +wss://primarycare.app +wss://hodlr.rocks +wss://superlinks.me +wss://mastodon.lawprofs.org +wss://tictoc.social +wss://nostest.dojotunnel.online +wss://kafka.icu +wss://nostr.lanparty.one +wss://filter.nostr.wineglobaltrue +wss://social.b10m.net +wss://worm.pink +wss://nostr-test.cx.ms +wss://nostrverifired.com +wss://relay.nostrss.re +wss://eliitin-some.fi +wss://dju.social +wss://jeremy.hu +wss://stream.criminallycute.fi +wss://nostr.0x50.dev +wss://n.s.nyc +wss://n.8.s.nyc +wss://relay.rocks +wss://relay.fiatjaf.com +wss://n-lan.s.nyc +wss://sciences.social +wss://nostr.swiss-enigma.com +wss://quietplace.xyz +wss://universe.nostrich.landlangenlangzh +wss://5280.city +wss://feddit.de +wss://base.lc +wss://social.medusmedia.com +wss://umha4zl6xk62a4dous6e7tq4qlmt462hlzs2su33en6qrvtvs3hkjgid.onion +wss://etorneau.fr:28343 +wss://foggyminds.com +wss://neurodiversity-in.au +wss://nostr.hendrixson.net +wss://ramen-fsm.eu.org +wss://www.nostrical.com +wss://feedbeat.me +wss://relay.bsky.social +wss://babka.social +wss://mastodontech.de +wss://commiespace.duckdns.org +wss://openbiblio.social +wss://karkatdyinginagluetrap.com +wss://relay.fundr.vanderwarker.family +wss://hannover.town +wss://nostr.relay.info +wss://mastodon.tetaneutral.net +wss://cache2.primal.net +wss://nostr.petrkr.net +wss://ifwo.eu +wss://mastodong.lol +wss://venera.social +wss://wallets.fyoumoneypod.com +wss://nostr.relay-nokotaro.com +wss://social.exozy.me +wss://gqgjp2bun4opme6mepz3rrgprkw4xatb6h5ogayorqwi6sajsxcp5sad.local +wss://branle.netlify.app +wss://rsslay.fiat.jaf +wss://chrislace.damus.io +wss://relay.leafbodhi.com +wss://social.opendesktop.org +wss://relay.nostr.watch +wss://gearlandia.haus +wss://freiburg.social +wss://atlas.nostro.land +wss://nostr.io +wss://patrizio.tn.al +wss://chaintools.io +wss://kn.icu +wss://relaywithme.eu +wss://la-autopilot-this-end-up.dvm.email +wss://coinfinity.co +wss://assemblag.es +wss://qou7zzll2mxx2ehl73n6pptmhizl5b3entowljlin3sqhcvltxdtlmad.onion +wss://indigenouscreatives.social +wss://bookwyrm.social +wss://kokoro.shugetsu.space +wss://eden.nost.land +wss://misskey.gothloli.club +wss://sersleepy.com +wss://rsslay.nos.pink +wss://pettingzoo.co +wss://witches.live +wss://7craxnzfi42touzi23etut5qjzqro27sqcuottxj7opntcin4fstruad.onion +wss://mastodonsweden.se +wss://mv2k.com +wss://nostr.tchaicap.space +wss://relay.whoop.ph +wss://bitcoiner.nostr.social +wss://kinkyelephant.com +wss://www.superstork.org +wss://mastodon.iftas.org +wss://lea.pet +wss://zug.network +wss://homeserver.local:4848 +wss://frontrange.co +wss://sciencemastodon.com +wss://montereybay.social +wss://social.securecryptomining.com +wss://rssrelay.nostr.moe +wss://nostr.org +wss://nostr.tw +wss://nostr.hk +wss://wxw.moe +wss://mastodonmusic.social +wss://puntarella.party +wss://oyasumi.space +wss://drumstodon.net +wss://social.wikimedia.de +wss://iyasaretai.pw +wss://social.coletivos.org +wss://nostream.localtest.me +wss://ubuntu201.local:4848 +wss://masto.pt +wss://taiwan.riley-tech.net +wss://freeradical.zone +wss://blastrf7z.xyz +wss://onemorestop.photo +wss://frikiverse.zone +wss://toot.bldrweb.org +wss://electroverse.tech +wss://mstdn.games +wss://relay1.nostr.unitedfop.com +wss://gratefuldread.masto.host +wss://mk.gabe.rocks +wss://widerweb.org +wss://mastodon.eternalaugust.com +wss://lay.southeastasia.cloudapp.azure.com:445 +wss://me.ns.ci +wss://gnostr.th +wss://fritter.cn +wss://nex.cn +wss://nex.tw +wss://fritter.jp +wss://fritter.tw +wss://gnostr.cn +wss://mstdn.dk +wss://akkoma.simulacrum-emporium.eu +wss://dalliance.social +wss://toot.re +wss://avatastic.uk +wss://blastr20f7z.xyz +wss://the.voiceover.bar +wss://porcodon.net +wss://nostr.dncn.xyz +wss://nostr.dnxn.xyz +wss://relay.xmr.rocks +wss://d6egak3woofrixu26gr3utb5qezhkktavsuwlrfqaauu55lmpudxudqd.onion:5051 +wss://social.wuebbsy.com +wss://4kgwkcfzea2xhefsquktyxqyjf3rsxa7oo7hxbcs6k3xdpxznknydsqd.local +wss://4kgwkcfzea2xhefsquktyxqyjf3rsxa7oo7hxbcs6k3xdpxznknydsqd.onion +wss://tooters.org +wss://nostre.wine +wss://relay.xplive.local +wss://relay2.xplive.local +wss://4v5umvicfs6a7d3aiy67uu2ibttiuanl2cehfmv5qaorbojousbgkdad.onion +wss://pipou.academy +wss://opjk6jxrcyicuwhe62tqy6zwx776u7rfi6cqo6iodurjvege7piz5wqd.local +wss://mythology.social +wss://nostr.millou.lol +wss://ryogrid.net:7777 +wss://nost.debancariser.com +wss://fault.stsecurity.moe +wss://jan-optiplex-5040.local:4848 +wss://relay-jp.wirednet.jp +wss://mastodon.kitchen +wss://relay.mnethome.de +wss://verkehrswende.social +wss://nostr.gleeze.com +wss://dair-community.social +wss://shota.house +wss://kavlak.uk +wss://social.inex.rocks +wss://4v5umvicfs6a7d3aiy67uu2ibttiuanl2cehfmv5qaorbojousbgkdad.local +wss://startrekshitposting.com +wss://nostrfmar.ddns.net +wss://4yqp7gzuf15zfc3hpwhz3j5p2uarvdsnf75ovpdiqvyjdsmku771jfid.onion +wss://nostrgraph.net +wss://idolheaven.org +wss://mstdn.kemono-friends.info +wss://mastodon.bawue.social +wss://social.pmj.rocks +wss://ursal.zone +wss://nstr.milou.lol +wss://poweredbygay.social +wss://gochisou.photo +wss://lnb3.openchain.fr +wss://nostr.filmweb.pl +wss://nostr-word.h3z.jp +wss://blastr.f7z.xyzanotherinstanceofblastr +wss://shakedown.social +wss://nostr.bubu.hair +wss://hyper-nostr.inosta.cc +wss://ieji.de +wss://wawmartme.com +wss://nostr.kungfu-g.rip +wss://bozgor.org +wss://todon.nl +wss://nostpy.lol +wss://ostatus.taiyolab.com +wss://polsum.rocks +wss://freespeech.group +wss://im.allmendenetz.de +wss://shitpost.poridge.club +wss://twingyeo.kr +wss://social.platypush.tech +wss://rsslay-production-bc22.up.railway.app +wss://lonely.damus.io +wss://kirche.social +wss://cubalibre.social +wss://relay.txinito.xyz +wss://realy.orangepill.dev +wss://3zi.ru +wss://plebstr.com +wss://social.seattle.wa.us +wss://social.bim.land +wss://cubhub.social +wss://relay.nostr-x.com +wss://hispagatos.space +wss://node101.nostress.cc +wss://lsbt.me +wss://jgqaglhautb4k6e6i2g34jakxiemqp6z4wynlirltuukgkft2xuglmqd.onion +wss://nostdemo.dojotunnel.online +wss://f.cz \ No newline at end of file diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt new file mode 100644 index 0000000000..f8d071fed9 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.jackson + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.Assert.assertEquals +import org.junit.Test + +class InliningTagArrayPrettyPrinterTest { + val mapper = + jacksonObjectMapper().apply { + setDefaultPrettyPrinter(InliningTagArrayPrettyPrinter()) + } + + @Test + fun test() { + val writer = mapper.writerWithDefaultPrettyPrinter() + + val data = + mapOf( + "tags" to arrayOf(intArrayOf(1, 2, 3), intArrayOf(4, 5, 6), intArrayOf(7, 8, 9)), + ) + val expected = + """ + { + "tags": [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ] + } + """.trimIndent() + val json = writer.writeValueAsString(data) + assertEquals(expected, json) + + val data2 = + mapOf( + "tags" to arrayOf(arrayOf(intArrayOf(1, 2), intArrayOf(3, 4)), arrayOf(intArrayOf(5, 6), intArrayOf(7, 8))), + ) + val expected2 = + """ + { + "tags": [ + [[1, 2], [3, 4]], + [[5, 6], [7, 8]] + ] + } + """.trimIndent() + val json2 = writer.writeValueAsString(data2) + assertEquals(expected2, json2) + } + + @Test + fun testEvent() { + val nostrObject = + """ + { + "id": "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", + "pubkey": "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + "created_at": 1740669816, + "kind": 0, + "tags": [ + ["alt", "User profile for Vitor"], + ["name", "Vitor"] + ], + "content": "{\"name\":\"Vitor\"}", + "sig": "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4" + } + """.trimIndent() + + val tree = mapper.readTree(nostrObject) + + val prettified = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(tree) + + assertEquals(nostrObject, prettified) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt new file mode 100644 index 0000000000..563dcd0816 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata + +import com.vitorpamplona.quartz.utils.nsecToSigner +import org.junit.Assert.assertEquals +import org.junit.Test + +class UpdateMetadataTest { + val signer = "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToSigner() + + @Test + fun createNewMetadata() { + val test = signer.sign(MetadataEvent.createNew("Vitor", createdAt = 1740669816)) + + val expected = + """ + { + "id": "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", + "pubkey": "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + "created_at": 1740669816, + "kind": 0, + "tags": [ + ["alt", "User profile for Vitor"], + ["name", "Vitor"] + ], + "content": "{\"name\":\"Vitor\"}", + "sig": "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4" + } + """.trimIndent() + + assertEquals(expected, test.toPrettyJson()) + } + + @Test + fun updateMetadata() { + val test = + signer.sign( + MetadataEvent.createNew( + name = "Vitor", + displayName = "Vitor Pamplona", + about = "Nostr's Chief Android Officer - #Amethyst", + picture = "https://vitorpamplona.com/images/me_300.jpg", + banner = "https://pbs.twimg.com/profile_banners/15064756/1414451651/1080x360", + pronouns = "he/him", + website = "https://vitorpamplona.com", + nip05 = "_@vitorpamplona.com", + lnAddress = "vitor@vitorpamplona.com", + lnURL = "TEST", + github = "https://gist.github.com/vitorpamplona/cf19e2d1d7f8dac6348ad37b35ec8421", + createdAt = 1740669816, + ), + ) + + val expected = + """ + { + "id": "2b2761d66db4a83d5fb7a98cafb8414e9b0c238ceb67d729bedacc1a092d516f", + "pubkey": "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + "created_at": 1740669816, + "kind": 0, + "tags": [ + ["alt", "User profile for Vitor"], + ["name", "Vitor"], + ["display_name", "Vitor Pamplona"], + ["picture", "https://vitorpamplona.com/images/me_300.jpg"], + ["banner", "https://pbs.twimg.com/profile_banners/15064756/1414451651/1080x360"], + ["website", "https://vitorpamplona.com"], + ["pronouns", "he/him"], + ["about", "Nostr's Chief Android Officer - #Amethyst"], + ["nip05", "_@vitorpamplona.com"], + ["lud16", "vitor@vitorpamplona.com"], + ["lud06", "TEST"], + ["i", "github:vitorpamplona", "cf19e2d1d7f8dac6348ad37b35ec8421"] + ], + "content": "{\"name\":\"Vitor\",\"display_name\":\"Vitor Pamplona\",\"picture\":\"https://vitorpamplona.com/images/me_300.jpg\",\"banner\":\"https://pbs.twimg.com/profile_banners/15064756/1414451651/1080x360\",\"website\":\"https://vitorpamplona.com\",\"pronouns\":\"he/him\",\"about\":\"Nostr's Chief Android Officer - #Amethyst\",\"nip05\":\"_@vitorpamplona.com\",\"lud16\":\"vitor@vitorpamplona.com\",\"lud06\":\"TEST\"}", + "sig": "0a8c78eb0c5e0ba46e4781cc445fb7b6d275b434cead9231bd19b4f95671e3ab872264e50d4456d6036a84cc81e517bfa24229571519aabefcbce431e0c7163e" + } + """.trimIndent() + + assertEquals(expected, test.toPrettyJson()) + + val expected2 = + """ + { + "id": "2e1e57fae4e4baddac025ea0b49afc093f2aa27610a05e584184ed26b29d7590", + "pubkey": "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + "created_at": 1740669817, + "kind": 0, + "tags": [ + ["alt", "User profile for 2 Vitor"], + ["name", "2 Vitor"], + ["display_name", "2 Vitor Pamplona"], + ["picture", "2 https://vitorpamplona.com/images/me_300.jpg"], + ["banner", "2 https://pbs.twimg.com/profile_banners/15064756/1414451651/1080x360"], + ["website", "2 https://vitorpamplona.com"], + ["pronouns", "2 he/him"], + ["about", "2 Nostr's Chief Android Officer - #Amethyst"], + ["nip05", "2 _@vitorpamplona.com"], + ["lud16", "2 vitor@vitorpamplona.com"], + ["lud06", "2 TEST"], + ["i", "github:vitorpamplona", "2cf19e2d1d7f8dac6348ad37b35ec8421"] + ], + "content": "{\"name\":\"2 Vitor\",\"display_name\":\"2 Vitor Pamplona\",\"picture\":\"2 https://vitorpamplona.com/images/me_300.jpg\",\"banner\":\"2 https://pbs.twimg.com/profile_banners/15064756/1414451651/1080x360\",\"website\":\"2 https://vitorpamplona.com\",\"pronouns\":\"2 he/him\",\"about\":\"2 Nostr's Chief Android Officer - #Amethyst\",\"nip05\":\"2 _@vitorpamplona.com\",\"lud16\":\"2 vitor@vitorpamplona.com\",\"lud06\":\"2 TEST\"}", + "sig": "a25483bc0fcc79ccd337e3ff846351097109fc13f1cd1c9cc15f7f2ad46417aeb7c05c1524aeadbab93036fc0743c8c310a5b2b7b482e1808649b12a3ee29d49" + } + """.trimIndent() + + val test2 = + signer.sign( + MetadataEvent.updateFromPast( + latest = test, + name = "2 Vitor", + displayName = "2 Vitor Pamplona", + about = "2 Nostr's Chief Android Officer - #Amethyst", + picture = "2 https://vitorpamplona.com/images/me_300.jpg", + banner = "2 https://pbs.twimg.com/profile_banners/15064756/1414451651/1080x360", + pronouns = "2 he/him", + website = "2 https://vitorpamplona.com", + nip05 = "2 _@vitorpamplona.com", + lnAddress = "2 vitor@vitorpamplona.com", + lnURL = "2 TEST", + github = "https://gist.github.com/vitorpamplona/2cf19e2d1d7f8dac6348ad37b35ec8421", + createdAt = 1740669817, + ), + ) + + assertEquals(expected2, test2.toPrettyJson()) + + val expected3 = + """ + { + "id": "94879b9a27fecf1337ede32013006ec4dfd5a3286a1e819abddfa1c3c132f008", + "pubkey": "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + "created_at": 1740669817, + "kind": 0, + "tags": [ + ["alt", "User profile for 2 Vitor"], + ["name", "2 Vitor"], + ["picture", "2 https://vitorpamplona.com/images/me_300.jpg"], + ["banner", "2 https://pbs.twimg.com/profile_banners/15064756/1414451651/1080x360"] + ], + "content": "{\"name\":\"2 Vitor\",\"picture\":\"2 https://vitorpamplona.com/images/me_300.jpg\",\"banner\":\"2 https://pbs.twimg.com/profile_banners/15064756/1414451651/1080x360\"}", + "sig": "729ee02364b4d429b6a66400ec850e80424602e3981ff2ad8a14d61bdee04f76ec68ddc4a0f38b0527f0218f7fa67668012a5c0f3c8ef943517b504040caa07e" + } + """.trimIndent() + + val test3 = + signer.sign( + MetadataEvent.updateFromPast( + latest = test2, + name = null, + displayName = "", + about = "", + picture = null, + banner = null, + pronouns = "", + website = "", + nip05 = "", + lnAddress = "", + lnURL = "", + github = "", + createdAt = 1740669817, + ), + ) + + assertEquals(expected3, test3.toPrettyJson()) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Test.kt index 7e9752278e..aaa870e136 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Test.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip96FileStorage +import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfoParser import junit.framework.TestCase.assertEquals import org.junit.Test diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt new file mode 100644 index 0000000000..0ccda6f689 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +class HexEncodingTest { + val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + + @Test + fun testHexEncodeDecodeOurs() { + assertEquals( + testHex, + Hex.encode( + Hex.decode(testHex), + ), + ) + } + + @Test + fun testIsHex() { + assertFalse("/0", Hex.isHex("/0")) + assertFalse("/.", Hex.isHex("/.")) + assertFalse("::", Hex.isHex("::")) + assertFalse("!!", Hex.isHex("!!")) + assertFalse("@@", Hex.isHex("@@")) + assertFalse("GG", Hex.isHex("GG")) + assertFalse("FG", Hex.isHex("FG")) + assertFalse("`a", Hex.isHex("`a")) + assertFalse("gg", Hex.isHex("gg")) + assertFalse("fg", Hex.isHex("fg")) + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRandomsIsHex() { + for (i in 0..10000) { + val bytes = Random.nextBytes(32) + val hex = bytes.toHexString(HexFormat.Default) + assertTrue(hex, Hex.isHex(hex)) + val hexUpper = bytes.toHexString(HexFormat.UpperCase) + assertTrue(hexUpper, Hex.isHex(hexUpper)) + } + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRandomsUppercase() { + for (i in 0..1000) { + val bytes = Random.nextBytes(32) + val hex = bytes.toHexString(HexFormat.UpperCase) + assertEquals( + bytes.toList(), + Hex.decode(hex).toList(), + ) + } + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/utils/RuntimeExt.kt b/quartz/src/test/java/com/vitorpamplona/quartz/utils/RuntimeExt.kt new file mode 100644 index 0000000000..f43acce2ec --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/utils/RuntimeExt.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +fun Runtime.usedMemoryMb(): Long { + val totalMemoryMb = totalMemory() / (1024 * 1024) + val freeMemoryMb = freeMemory() / (1024 * 1024) + return totalMemoryMb - freeMemoryMb +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/AuthToken.kt b/quartz/src/test/java/com/vitorpamplona/quartz/utils/SignerUtils.kt similarity index 75% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/AuthToken.kt rename to quartz/src/test/java/com/vitorpamplona/quartz/utils/SignerUtils.kt index d0a0d6de3f..dafde3c62f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/AuthToken.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/utils/SignerUtils.kt @@ -18,14 +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.nip96FileStorage +package com.vitorpamplona.quartz.utils -import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import java.util.Base64 +import com.vitorpamplona.quartz.nip01Core.crypto.DeterministicSigner +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes -class AuthToken { - fun encodeAuth(event: HTTPAuthorizationEvent): String { - val encodedNIP98Event = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) - return "Nostr $encodedNIP98Event" - } -} +fun String.nsecToKeyPair() = KeyPair(this.bechToBytes()) + +fun String.nsecToSigner() = this.nsecToKeyPair().let { DeterministicSigner(it) } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/utils/TimeUtilsTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/utils/StringUtilsTest.kt similarity index 99% rename from quartz/src/test/java/com/vitorpamplona/quartz/utils/TimeUtilsTest.kt rename to quartz/src/test/java/com/vitorpamplona/quartz/utils/StringUtilsTest.kt index 19b2962386..54d772977a 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/utils/TimeUtilsTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/utils/StringUtilsTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.utils import junit.framework.TestCase import org.junit.Test -class TimeUtilsTest { +class StringUtilsTest { private val test = """Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32.