diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b72e8e47..2093d0bb1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,15 +42,30 @@ jobs: - name: Build APK (gradle) run: ./gradlew assembleDebug --no-daemon - - name: Upload APK + - name: Upload Play APK uses: actions/upload-artifact@v4 with: - name: Debug APK - path: amethyst/build/outputs/apk/debug/app-debug.apk + name: Play Debug APK + path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk + + - name: Upload FDroid APK + uses: actions/upload-artifact@v4 + with: + name: FDroid Debug APK + path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk + + - name: Upload Compose Reports + uses: actions/upload-artifact@v4 + with: + name: Compose Reports + path: amethyst/build/compose_compiler + + - name: Tests + run: ./gradlew test --no-daemon - name: Upload Test Results uses: actions/upload-artifact@v4 with: - name: Build Reports + name: Test Reports path: amethyst/build/reports diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index d4b7accba..bb4493707 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 8e59b99b1..851ee8374 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Join the social network you control. ## Download and Install +[](https://github.com/zapstore/zapstore/releases) [Get it on Obtaininum](https://github.com/ImranR98/Obtainium) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 40a9e17b4..20bc1e955 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -16,9 +16,9 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk libs.versions.android.minSdk.get().toInteger() targetSdk libs.versions.android.targetSdk.get().toInteger() - versionCode 412 - versionName "0.92.7" - buildConfigField "String", "RELEASE_NOTES_ID", "\"98f2acaf9b3eb70d87607942e576f9a086ac28adf37869da9a29f4815cecac86\"" + versionCode 414 + versionName "0.93.1" + buildConfigField "String", "RELEASE_NOTES_ID", "\"f9e228d0579b0256044b54a78037f06edfa06c24862c368ad7e02b5caa7bd309\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -233,9 +233,6 @@ dependencies { // enables network for coil implementation libs.coil.okhttp - // create blurhash - implementation libs.trbl.blurhash - // Permission to upload pictures: implementation libs.accompanist.permissions diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index 856518bec..495c1ed7a 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -26,12 +26,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.service.FileHeader -import com.vitorpamplona.amethyst.service.Nip96MediaServers -import com.vitorpamplona.amethyst.service.Nip96Retriever -import com.vitorpamplona.amethyst.service.Nip96Uploader -import com.vitorpamplona.amethyst.ui.actions.ImageDownloader +import com.vitorpamplona.amethyst.service.uploads.FileHeader +import com.vitorpamplona.amethyst.service.uploads.ImageDownloader +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader +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.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.toHexKey import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail import kotlinx.coroutines.CoroutineScope @@ -47,63 +52,98 @@ import kotlin.random.Random @RunWith(AndroidJUnit4::class) class ImageUploadTesting { - private suspend fun testBase(server: Nip96MediaServers.ServerName) { - val serverInfo = - Nip96Retriever() - .loadInfo( - server.baseUrl, - false, - ) + val account = + Account( + AccountSettings(KeyPair()), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ) + private suspend fun getBitmap(): ByteArray { val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888) for (x in 0 until bitmap.width) { for (y in 0 until bitmap.height) { bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt())) } } + val baos = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos) - val bytes = baos.toByteArray() - val inputStream = bytes.inputStream() + return baos.toByteArray() + } - val account = - Account( - AccountSettings(KeyPair()), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - ) + private suspend fun testBase(server: ServerName) { + if (server.type == ServerType.NIP96) { + testNip96(server) + } else { + testBlossom(server) + } + } + private suspend fun testBlossom(server: ServerName) { + val paylod = getBitmap() + val initialHash = CryptoUtils.sha256(paylod).toHexKey() + val inputStream = paylod.inputStream() val result = - Nip96Uploader(account) + BlossomUploader() .uploadImage( - inputStream, - bytes.size.toLong(), - "image/png", + inputStream = inputStream, + hash = initialHash, + length = paylod.size, + baseFileName = "filename.png", + contentType = "image/png", alt = null, sensitiveContent = null, - serverInfo, + serverBaseUrl = server.baseUrl, forceProxy = { false }, - onProgress = {}, + httpAuth = account::createBlossomUploadAuth, context = InstrumentationRegistry.getInstrumentation().targetContext, ) - val url = result.tags!!.first { it[0] == "url" }.get(1) - val size = - result.tags!! - .firstOrNull { it[0] == "size" } - ?.get(1) - ?.ifBlank { null } - val dim = - result.tags!! - .firstOrNull { it[0] == "dim" } - ?.get(1) - ?.ifBlank { null } - val hash = - result.tags!! - .firstOrNull { it[0] == "x" } - ?.get(1) - ?.ifBlank { null } - val contentType = result.tags!!.first { it[0] == "m" }.get(1) - val ox = result.tags!!.first { it[0] == "ox" }.get(1) + assertEquals("image/png", result.type) + assertEquals(paylod.size.toLong(), result.size) + assertEquals(initialHash, result.sha256) + assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png")) + + val imageData: ByteArray = + ImageDownloader().waitAndGetImage(result.url!!, false) + ?: run { + fail("${server.name}: Should not be null") + return + } + + val downloadedHash = CryptoUtils.sha256(imageData).toHexKey() + assertEquals(initialHash, downloadedHash) + } + + private suspend fun testNip96(server: ServerName) { + val serverInfo = + ServerInfoRetriever() + .loadInfo( + server.baseUrl, + false, + ) + + val paylod = getBitmap() + val inputStream = paylod.inputStream() + val result = + Nip96Uploader() + .uploadImage( + inputStream = inputStream, + length = paylod.size.toLong(), + contentType = "image/png", + alt = null, + sensitiveContent = null, + server = serverInfo, + forceProxy = { false }, + onProgress = {}, + httpAuth = account::createHTTPAuthorization, + context = InstrumentationRegistry.getInstrumentation().targetContext, + ) + + val url = result.url!! + val size = result.size + val dim = result.dimension + val hash = result.sha256 Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http")) @@ -114,22 +154,23 @@ class ImageUploadTesting { return } - FileHeader.prepare( - imageData, - "image/png", - null, - onReady = { + val prepared = + FileHeader.prepare( + imageData, + "image/png", + null, + ) + + prepared.fold( + onSuccess = { if (dim != null) { - // assertEquals("${server.name}: Invalid dimensions", it.dim, dim) + assertEquals("${server.name}: Invalid dimensions", it.dim.toString(), dim.toString()) } if (size != null) { - // assertEquals("${server.name}: Invalid size", it.size.toString(), size) - } - if (hash != null) { - assertEquals("${server.name}: Invalid hash", it.hash, hash) + assertEquals("${server.name}: Invalid size", it.size.toString(), size) } }, - onError = { fail("${server.name}: It should not fail") }, + onFailure = { fail("${server.name}: It should not fail") }, ) // delay(1000) @@ -140,66 +181,88 @@ class ImageUploadTesting { @Test fun runTestOnDefaultServers() = runBlocking { - Nip96MediaServers.DEFAULT.forEach { - testBase(it) + DEFAULT_MEDIA_SERVERS.forEach { + // skip paid servers and primal server is buggy. + if (!it.name.contains("Paid") && !it.name.contains("Primal")) { + testBase(it) + } } } @Test() fun testNostrCheck() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me")) + testBase(ServerName("nostrcheck.me", "https://nostrcheck.me", ServerType.NIP96)) } @Test() @Ignore("Not Working anymore") fun testNostrage() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com")) + testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96)) } @Test() @Ignore("Not Working anymore") fun testSove() = runBlocking { - testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent")) + testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96)) } @Test() fun testNostrBuild() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build")) + testBase(ServerName("nostr.build", "https://nostr.build", ServerType.NIP96)) } @Test() @Ignore("Not Working anymore") fun testSovbit() = runBlocking { - testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host")) + testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96)) } @Test() fun testVoidCat() = runBlocking { - testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat")) + testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96)) } @Test() + @Ignore("Returns invalid image size") fun testNostrPic() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com")) + testBase(ServerName("nostpic.com", "https://nostpic.com", ServerType.NIP96)) } @Test(expected = RuntimeException::class) fun testSprovoostNl() = runBlocking { - testBase(Nip96MediaServers.ServerName("sprovoost.nl", "https://img.sprovoost.nl/")) + testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96)) } @Test() @Ignore("Not Working anymore") fun testNostrOnch() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services")) + testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96)) + } + + @Ignore("Changes sha256") + fun testPrimalBlossom() = + runBlocking { + testBase(ServerName("primal.net", "https://blossom.primal.net", ServerType.Blossom)) + } + + @Test() + fun testNostrCheckBlossom() = + runBlocking { + testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom)) + } + + @Ignore("Requires Payment") + fun testSatelliteBlossom() = + runBlocking { + testBase(ServerName("satellite", "https://cdn.satellite.earth", ServerType.Blossom)) } } diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.kt new file mode 100644 index 000000000..a8c751dbd --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.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 com.vitorpamplona.amethyst.ui.navigation.findParameterValue +import junit.framework.TestCase.assertEquals +import org.junit.Test +import java.net.URI + +class URIParserTest { + @Test + fun testNEventWithAccount() { + val test = "nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val testUri = URI(test) + + // assertEquals("nostr", testUri.scheme) + assertEquals(null, testUri.authority) + assertEquals(null, testUri.host) + assertEquals(-1, testUri.port) + assertEquals(null, testUri.userInfo) + assertEquals("nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku", testUri.path) + assertEquals(null, testUri.fragment) + assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery) + + assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account")) + } + + @Test + fun testNotificationsWithAccount() { + val test = "notifications?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val testUri = URI(test) + + // assertEquals("nostr", testUri.scheme) + assertEquals(null, testUri.authority) + assertEquals(null, testUri.host) + assertEquals(-1, testUri.port) + assertEquals(null, testUri.userInfo) + assertEquals("notifications", testUri.path) + assertEquals(null, testUri.fragment) + assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery) + + assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account")) + } +} diff --git a/amethyst/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt b/amethyst/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt index 3d22f9ae0..0c48f3f55 100644 --- a/amethyst/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt +++ b/amethyst/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt @@ -27,6 +27,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.CancellationException @RunWith(AndroidJUnit4::class) class TranslationsTest { @@ -124,7 +125,10 @@ class TranslationsTest { "Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", "pt", ) + } + @Test(expected = CancellationException::class) + fun testTranslationLnInvoice2() { assertTranslateContains( "lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", "Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", @@ -132,7 +136,7 @@ class TranslationsTest { ) } - @Test + @Test(expected = CancellationException::class) fun testNostrEvents() { assertTranslateContains( "nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", @@ -162,4 +166,15 @@ class TranslationsTest { "pt", ) } + + @Test + fun testHttp() { + val text = "https://m.primal.net/MdDd.png \nRunning... \uD83D\uDE01 nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm" + + assertTranslateContains( + "https://m.primal.net/MdDd.png", + text, + "pt", + ) + } } diff --git a/amethyst/src/fdroid/AndroidManifest.xml b/amethyst/src/fdroid/AndroidManifest.xml index c4ef213f9..62202f91f 100644 --- a/amethyst/src/fdroid/AndroidManifest.xml +++ b/amethyst/src/fdroid/AndroidManifest.xml @@ -1,6 +1,5 @@ - + diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 049a2f5ad..d5249d412 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -19,12 +19,8 @@ - - - - - + @@ -120,10 +116,6 @@ - - + + + + + + \ No newline at end of file diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 1de3edec1..571246428 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst import android.app.Application import android.content.ContentResolver import android.content.Context +import android.content.IntentFilter +import android.os.Build import android.os.Looper import android.os.StrictMode import android.os.StrictMode.ThreadPolicy @@ -31,12 +33,13 @@ import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import coil3.ImageLoader import coil3.disk.DiskCache -import coil3.disk.directory import coil3.memory.MemoryCache -import coil3.request.crossfade import com.vitorpamplona.amethyst.service.LocationState +import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.service.playback.VideoCache -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.ammolite.relays.NostrClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -52,12 +55,17 @@ import kotlin.time.measureTimedValue class Amethyst : Application() { val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val client: NostrClient = NostrClient(OkHttpWebSocket.Builder()) + // Service Manager is only active when the activity is active. - val serviceManager = ServiceManager(applicationIOScope) + val serviceManager = ServiceManager(client, applicationIOScope) val locationManager = LocationState(this, applicationIOScope) + val pokeyReceiver = PokeyReceiver() + override fun onTerminate() { super.onTerminate() + unregisterReceiver(pokeyReceiver) applicationIOScope.cancel() } @@ -126,6 +134,19 @@ class Amethyst : Application() { } Log.d("Rendering Metrics", "VideoCache initialized in $elapsed") } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver( + pokeyReceiver, + IntentFilter(PokeyReceiver.POKEY_ACTION), + RECEIVER_EXPORTED, + ) + } else { + registerReceiver( + pokeyReceiver, + IntentFilter(PokeyReceiver.POKEY_ACTION), + ) + } } fun imageLoaderBuilder(): ImageLoader.Builder = @@ -133,7 +154,6 @@ class Amethyst : Application() { .Builder(this) .diskCache { coilCache } .memoryCache { memoryCache } - .crossfade(true) fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index e6998b047..0c63f8b20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -43,11 +43,9 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.NostrVideoDataSource -import com.vitorpamplona.ammolite.relays.Client -import com.vitorpamplona.ammolite.relays.RelayPool fun debugState(context: Context) { - Client + Amethyst.instance.client .allSubscriptions() .forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toDebugJson() }}") } @@ -89,7 +87,7 @@ fun debugState(context: Context) { Log.d("STATE DUMP", "Memory Class " + memClass + " MB (largeHeap $isLargeHeap)") } - Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays()) + Log.d("STATE DUMP", "Connected Relays: " + Amethyst.instance.client.connectedRelays()) Log.d( "STATE DUMP", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index abef26950..7413c1a4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -37,8 +37,9 @@ import com.vitorpamplona.amethyst.model.DefaultZapAmounts import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.Settings -import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType @@ -379,6 +380,12 @@ object LocalPreferences { remove(PrefKeys.LATEST_SEARCH_RELAY_LIST) } + if (settings.localRelayServers.isNotEmpty()) { + putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers) + } else { + remove(PrefKeys.LOCAL_RELAY_SERVERS) + } + if (settings.backupMuteList != null) { putString( PrefKeys.LATEST_MUTE_LIST, @@ -434,6 +441,7 @@ object LocalPreferences { }.apply() } } + Log.d("LocalPreferences", "Saved to encrypted storage") } suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) } @@ -520,7 +528,7 @@ object LocalPreferences { val localRelays = parseOrNull>(PrefKeys.RELAYS) ?: emptySet() val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) - val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0] + val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] val pendingAttestations = parseOrNull>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt new file mode 100644 index 000000000..5673cb720 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.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.amethyst + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +/** + * Launches an async coroutine for each item, runs the + * function and waits for everybody to finsih + */ +suspend fun launchAndWaitAll( + items: List, + asyncFunc: suspend (T) -> Unit, +) { + coroutineScope { + val jobs = + items.map { next -> + async { + asyncFunc(next) + } + } + + // runs in parallel to avoid overcrowding Amber. + withTimeoutOrNull(15000) { + jobs.joinAll() + } + } +} + +/** + * Runs the function and waits for 10 seconds for any result. + */ +suspend inline fun tryAndWait( + timeoutMillis: Long = 10000, + crossinline asyncFunc: (Continuation) -> Unit, +): T? = + withTimeoutOrNull(timeoutMillis) { + suspendCancellableCoroutine { continuation -> + asyncFunc(continuation) + } + } + +/** + * Runs an async coroutine for each one of the items, + * runs the request for that item, + * and gathers all the results in the output map. + */ +suspend fun collectSuccessfulOperations( + items: List, + runRequestFor: (T, (K) -> Unit) -> Unit, + output: MutableList = mutableListOf(), + onReady: suspend (List) -> Unit, +) { + if (items.isEmpty()) { + onReady(output) + return + } + + launchAndWaitAll(items) { + val result = + tryAndWait { continuation -> + runRequestFor(it) { result: K -> continuation.resume(result) } + } + + if (result != null) { + output.add(result) + } + } + + onReady(output) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 4dfd207d4..180809ed0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -33,6 +33,7 @@ import coil3.util.DebugLogger import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.Base64Fetcher +import com.vitorpamplona.amethyst.service.BlurHashFetcher import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource @@ -49,12 +50,12 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.NostrVideoDataSource +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer 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.Client -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.encoders.toHexKey @@ -72,10 +73,12 @@ import kotlinx.coroutines.runBlocking @Stable class ServiceManager( + val client: NostrClient, val scope: CoroutineScope, ) { - private var isStarted: Boolean = - false // to not open amber in a loop trying to use auth relays and registering for notifications + // to not open amber in a loop trying to use auth relays and registering for notifications + private var isStarted: Boolean = false + private var account: Account? = null private var collectorJob: Job? = null @@ -134,6 +137,9 @@ class ServiceManager( } add(SvgDecoder.Factory()) add(Base64Fetcher.Factory) + add(BlurHashFetcher.Factory) + add(Base64Fetcher.BKeyer) + add(BlurHashFetcher.BKeyer) add( OkHttpNetworkFetcherFactory( callFactory = { @@ -152,7 +158,7 @@ class ServiceManager( if (myAccount != null) { val relaySet = myAccount.connectToRelaysWithProxy.value - Client.reconnect(relaySet) + client.reconnect(relaySet) collectorJob?.cancel() collectorJob = null @@ -161,7 +167,7 @@ class ServiceManager( myAccount.connectToRelaysWithProxy.collectLatest { delay(500) if (isStarted) { - Client.reconnect(it, onlyIfChanged = true) + client.reconnect(it, onlyIfChanged = true) } } } @@ -227,7 +233,7 @@ class ServiceManager( NostrUserProfileDataSource.stopSync() NostrVideoDataSource.stopSync() - Client.reconnect(null) + client.reconnect(null) isStarted = 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 8ec6b4900..9f260fe79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.model -import android.location.Location import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable @@ -29,15 +28,21 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.liveData import androidx.lifecycle.switchMap import com.fasterxml.jackson.module.kotlin.readValue -import com.fonfon.kgeohash.toGeoHash +import com.fonfon.kgeohash.GeoHash import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import com.vitorpamplona.amethyst.service.uploads.FileHeader +import com.vitorpamplona.amethyst.tryAndWait +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet import com.vitorpamplona.amethyst.ui.tor.TorType -import com.vitorpamplona.ammolite.relays.Client import com.vitorpamplona.ammolite.relays.Constants import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.Relay @@ -45,22 +50,28 @@ import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.ammolite.relays.RelaySetupInfoToConnect import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.Dimension +import com.vitorpamplona.quartz.encoders.ETag +import com.vitorpamplona.quartz.encoders.EventHint import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.Nip47WalletConnect +import com.vitorpamplona.quartz.encoders.PTag import com.vitorpamplona.quartz.encoders.RelayUrlFormatter import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AppSpecificDataEvent +import com.vitorpamplona.quartz.events.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.events.BlossomServersEvent import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent -import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.Contact import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DeletionEvent @@ -79,6 +90,10 @@ import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent @@ -87,9 +102,12 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.NIP17Factory +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent +import com.vitorpamplona.quartz.events.PictureMeta import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -103,9 +121,12 @@ import com.vitorpamplona.quartz.events.Response import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.SearchRelayListEvent import com.vitorpamplona.quartz.events.StatusEvent +import com.vitorpamplona.quartz.events.StoryOption import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.TorrentCommentEvent +import com.vitorpamplona.quartz.events.VideoHorizontalEvent +import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.WrappedEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.signers.NostrSigner @@ -130,9 +151,8 @@ import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull +import org.czeal.rfc3986.URIReference import java.math.BigDecimal import java.util.Locale import java.util.UUID @@ -173,7 +193,7 @@ class Account( val listName: String, val peopleList: StateFlow = MutableStateFlow(NoteState(Note(" "))), val kind3: StateFlow = MutableStateFlow(null), - val location: StateFlow = MutableStateFlow(null), + val location: StateFlow = MutableStateFlow(null), ) val connectToRelaysFlow = @@ -544,7 +564,7 @@ class Account( AROUND_ME -> FeedsBaseFlows( listName, - location = Amethyst.instance.locationManager.locationStateFlow, + location = Amethyst.instance.locationManager.geohashStateFlow, ) else -> { val note = LocalCache.checkGetOrCreateAddressableNote(listName) @@ -562,30 +582,63 @@ class Account( } } + fun compute50kmLine(geoHash: GeoHash): List { + val hashes = mutableListOf() + + hashes.add(geoHash.toString()) + + var currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.westernNeighbour + hashes.add(currentGeoHash.toString()) + } + + currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.easternNeighbour + hashes.add(currentGeoHash.toString()) + } + + return hashes + } + + fun compute50kmRange(geoHash: GeoHash): List { + val hashes = mutableListOf() + + hashes.addAll(compute50kmLine(geoHash)) + + var currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.northernNeighbour + hashes.addAll(compute50kmLine(currentGeoHash)) + } + + currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.southernNeighbour + hashes.addAll(compute50kmLine(currentGeoHash)) + } + + return hashes + } + suspend fun mapIntoFollowLists( listName: String, kind3: LiveFollowList?, noteState: NoteState, - location: Location?, + location: LocationState.LocationResult?, ): LiveFollowList? = if (listName == GLOBAL_FOLLOWS) { null } else if (listName == KIND3_FOLLOWS) { kind3 } else if (listName == AROUND_ME) { - val hash = location?.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits) - if (hash != null) { + val geohashResult = location ?: Amethyst.instance.locationManager.geohashStateFlow.value + if (geohashResult is LocationState.LocationResult.Success) { // 2 neighbors deep = 25x25km - val hashes = - listOf(hash.toString()) + - hash.adjacent - .map { listOf(it.toString()) + it.adjacent.map { it.toString() } } - .flatten() - .distinct() - LiveFollowList( authorsPlusMe = setOf(signer.pubKey), - geotags = hashes.toSet(), + geotags = compute50kmRange(geohashResult.geoHash).toSet(), ) } else { LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) @@ -623,6 +676,19 @@ class Account( ) } + val liveServerList: StateFlow> by lazy { + combine(getFileServersListFlow(), getBlossomServersListFlow()) { nip96, blossom -> + mergeServerList(nip96.note.event as? FileServersEvent, blossom.note.event as? BlossomServersEvent) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + runBlocking { + mergeServerList(getFileServersList(), getBlossomServersList()) + }, + ) + } + suspend fun loadAndCombineFlows(listName: String): LiveFollowList? { val flows = loadFlowsFor(listName) return mapIntoFollowLists( @@ -899,11 +965,9 @@ class Account( } suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowList? = - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - decryptLiveFollows(peopleListFollows) { - continuation.resume(it) - } + tryAndWait { continuation -> + decryptLiveFollows(peopleListFollows) { + continuation.resume(it) } } @@ -921,25 +985,21 @@ class Account( } suspend fun decryptPeopleList(event: PeopleListEvent?): PeopleListEvent.UsersAndWords { - if (event == null) return PeopleListEvent.UsersAndWords() + if (event == null || !isWriteable()) return PeopleListEvent.UsersAndWords() - return withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - event.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } + return tryAndWait { continuation -> + event.publicAndPrivateUsersAndWords(signer) { + continuation.resume(it) } } ?: PeopleListEvent.UsersAndWords() } suspend fun decryptMuteList(event: MuteListEvent?): PeopleListEvent.UsersAndWords { - if (event == null) return PeopleListEvent.UsersAndWords() + if (event == null || !isWriteable()) return PeopleListEvent.UsersAndWords() - return withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - event.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } + return tryAndWait { continuation -> + event.publicAndPrivateUsersAndWords(signer) { + continuation.resume(it) } } ?: PeopleListEvent.UsersAndWords() } @@ -994,11 +1054,9 @@ class Account( emit(null) } else { emit( - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - userState.user.latestBookmarkList?.privateTags(signer) { - continuation.resume(userState.user.latestBookmarkList) - } + tryAndWait { continuation -> + userState.user.latestBookmarkList?.privateTags(signer) { + continuation.resume(userState.user.latestBookmarkList) } }, ) @@ -1106,7 +1164,7 @@ class Account( otherTags = emptyArray(), signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -1123,7 +1181,7 @@ class Account( relayUse = relays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -1137,7 +1195,7 @@ class Account( signer = signer, ) { // Keep this local to avoid erasing a good contact list. - // Client.send(it) + // Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -1152,6 +1210,7 @@ class Account( picture: String? = null, banner: String? = null, website: String? = null, + pronouns: String? = null, about: String? = null, nip05: String? = null, lnAddress: String? = null, @@ -1168,6 +1227,7 @@ class Account( picture = picture, banner = banner, website = website, + pronouns = pronouns, about = about, nip05 = nip05, lnAddress = lnAddress, @@ -1177,7 +1237,7 @@ class Account( github = github, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } @@ -1209,14 +1269,9 @@ class Account( return } - if (note.event is ChatMessageEvent) { - val event = note.event as ChatMessageEvent - val users = - event - .recipientsPubKey() - .plus(event.pubKey) - .toSet() - .toList() + val noteEvent = note.event + if (noteEvent is NIP17Group) { + val users = noteEvent.groupMembers().toList() if (reaction.startsWith(":")) { val emojiUrl = EmojiUrl.decode(reaction) @@ -1253,7 +1308,7 @@ class Account( if (emojiUrl != null) { note.event?.let { ReactionEvent.create(emojiUrl, it, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it) } } @@ -1264,7 +1319,7 @@ class Account( note.event?.let { ReactionEvent.create(reaction, it, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it) } } @@ -1339,7 +1394,7 @@ class Account( zappedNote?.isZappedBy(userProfile(), this, onWasZapped) } - fun calculateZappedAmount( + suspend fun calculateZappedAmount( zappedNote: Note?, onReady: (BigDecimal) -> Unit, ) { @@ -1370,7 +1425,7 @@ class Account( LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } } - Client.sendSingle( + Amethyst.instance.client.sendSingle( signedEvent = event, relayTemplate = RelaySetupInfoToConnect( @@ -1423,14 +1478,14 @@ class Account( note.event?.let { ReactionEvent.createWarning(it, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } note.event?.let { ReportEvent.create(it, type, signer, content) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -1448,7 +1503,7 @@ class Account( } ReportEvent.create(user.pubkeyHex, type, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -1465,22 +1520,52 @@ class Account( // chunks in 200 elements to avoid going over the 65KB limit for events. myNoteVersions.chunked(200).forEach { chunkedList -> DeletionEvent.create(chunkedList, signer) { deletionEvent -> - Client.send(deletionEvent) + Amethyst.instance.client.send(deletionEvent) LocalCache.justConsume(deletionEvent, null) } } } } - fun createHTTPAuthorization( + suspend fun createHTTPAuthorization( url: String, method: String, body: ByteArray? = null, - onReady: (HTTPAuthorizationEvent) -> Unit, - ) { - if (!isWriteable()) return + ): HTTPAuthorizationEvent? { + if (!isWriteable()) return null - HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady) + return tryAndWait { continuation -> + HTTPAuthorizationEvent.create(url, method, body, signer) { + continuation.resume(it) + } + } + } + + suspend fun createBlossomUploadAuth( + hash: HexKey, + size: Long, + alt: String, + ): BlossomAuthorizationEvent? { + if (!isWriteable()) return null + + return tryAndWait { continuation -> + BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) { + continuation.resume(it) + } + } + } + + suspend fun createBlossomDeleteAuth( + hash: HexKey, + alt: String, + ): BlossomAuthorizationEvent? { + if (!isWriteable()) return null + + return tryAndWait { continuation -> + BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) { + continuation.resume(it) + } + } } suspend fun boost(note: Note) { @@ -1494,12 +1579,12 @@ class Account( note.event?.let { if (it.kind() == 1) { RepostEvent.create(it, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { GenericRepostEvent.create(it, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -1510,7 +1595,7 @@ class Account( note.event?.let { if (it is WrappedEvent && it.host != null) { it.host?.let { - Client.sendFilterAndStopOnFirstResponse( + Amethyst.instance.client.sendFilterAndStopOnFirstResponse( filters = listOf( TypedFilter( @@ -1521,12 +1606,12 @@ class Account( ), ), onResponse = { - Client.send(it) + Amethyst.instance.client.send(it) }, ) } } else { - Client.send(it) + Amethyst.instance.client.send(it) } } } @@ -1540,7 +1625,7 @@ class Account( if (pair.value != newAttestation) { OtsEvent.create(pair.key, newAttestation, signer) { LocalCache.justConsume(it, null) - Client.send(it) + Amethyst.instance.client.send(it) settings.pendingAttestations.update { it - pair.key @@ -1571,7 +1656,7 @@ class Account( if (contactList != null) { ContactListEvent.followUser(contactList, user.pubkeyHex, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -1587,7 +1672,7 @@ class Account( }, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -1600,7 +1685,7 @@ class Account( if (contactList != null) { ContactListEvent.followEvent(contactList, channel.idHex, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -1616,7 +1701,7 @@ class Account( }, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -1629,7 +1714,7 @@ class Account( if (contactList != null) { ContactListEvent.followAddressableEvent(contactList, community.address, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -1646,7 +1731,7 @@ class Account( relayUse = relays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -1663,7 +1748,7 @@ class Account( tag, signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -1679,7 +1764,7 @@ class Account( }, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -1715,7 +1800,7 @@ class Account( } fun onNewEventCreated(event: Event) { - Client.send(event) + Amethyst.instance.client.send(event) LocalCache.justConsume(event, null) } @@ -1814,7 +1899,7 @@ class Account( hash = headerInfo.hash, size = headerInfo.size.toString(), dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash, + blurhash = headerInfo.blurHash?.blurhash, alt = alt, sensitiveContent = sensitiveContent, signer = signer, @@ -1833,10 +1918,10 @@ class Account( ): Note? { if (!isWriteable()) return null - Client.send(data, relayList = relayList) + Amethyst.instance.client.send(data, relayList = relayList) LocalCache.consume(data, null) - Client.send(signedEvent, relayList = relayList) + Amethyst.instance.client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent, null) return LocalCache.getNoteIfExists(signedEvent.id) @@ -1857,17 +1942,17 @@ class Account( signedEvent: FileStorageHeaderEvent, relayList: List, ) { - Client.send(data, relayList = relayList) - Client.send(signedEvent, relayList = relayList) + Amethyst.instance.client.send(data, relayList = relayList) + Amethyst.instance.client.send(signedEvent, relayList = relayList) } fun sendHeader( - signedEvent: FileHeaderEvent, + signedEvent: Event, relayList: List, onReady: (Note) -> Unit, ) { - Client.send(signedEvent, relayList = relayList) - LocalCache.consume(signedEvent, null) + Amethyst.instance.client.send(signedEvent, relayList = relayList) + LocalCache.justConsume(signedEvent, null) LocalCache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } } @@ -1890,7 +1975,7 @@ class Account( hash = headerInfo.hash, size = headerInfo.size.toString(), dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash, + blurhash = headerInfo.blurHash?.blurhash, alt = alt, originalHash = originalHash, sensitiveContent = sensitiveContent, @@ -1900,8 +1985,40 @@ class Account( } } + fun sendAllAsOnePictureEvent( + urlHeaderInfo: Map, + caption: String?, + sensitiveContent: Boolean, + relayList: List, + onReady: (Note) -> Unit, + ) { + val iMetas = + urlHeaderInfo.map { + PictureMeta( + it.key, + it.value.mimeType, + it.value.blurHash?.blurhash, + it.value.dim, + caption, + it.value.hash, + it.value.size.toLong(), + emptyList(), + emptyList(), + ) + } + + PictureEvent.create( + images = iMetas, + msg = caption, + markAsSensitive = sensitiveContent, + signer = signer, + ) { event -> + sendHeader(event, relayList = relayList, onReady) + } + } + fun sendHeader( - imageUrl: String, + url: String, magnetUri: String?, headerInfo: FileHeader, alt: String?, @@ -1912,20 +2029,70 @@ class Account( ) { 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, - alt = alt, - originalHash = originalHash, - sensitiveContent = sensitiveContent, - signer = signer, - ) { event -> - sendHeader(event, relayList = relayList, onReady) + 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) + } + } 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) + } + } + } 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) + } } } @@ -1944,7 +2111,7 @@ class Account( zapRaiserAmount: Long? = null, relayList: List, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String?, ) { if (!isWriteable()) return @@ -1971,7 +2138,7 @@ class Account( zapRaiserAmount = zapRaiserAmount, directMentions = directMentions, geohash = geohash, - nip94attachments = nip94attachments, + imetas = imetas, signer = signer, isDraft = draftTag != null, ) { @@ -1984,13 +2151,13 @@ class Account( } } } else { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) LocalCache.justConsume(it, null) - replyTo?.forEach { it.event?.let { 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 { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) } } } @@ -2011,7 +2178,7 @@ class Account( forkedFrom: Event?, relayList: List, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String?, ) { if (!isWriteable()) return @@ -2033,7 +2200,7 @@ class Account( root = root, directMentions = directMentions, geohash = geohash, - nip94attachments = nip94attachments, + imetas = imetas, forkedFrom = forkedFrom, signer = signer, isDraft = draftTag != null, @@ -2047,19 +2214,19 @@ class Account( } } } else { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) LocalCache.justConsume(it, null) // broadcast replied notes replyingTo?.let { LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) } } - replyTo?.forEach { it.event?.let { 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 { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) } } } @@ -2079,7 +2246,7 @@ class Account( forkedFrom: Event?, relayList: List, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String?, ) { if (!isWriteable()) return @@ -2099,7 +2266,7 @@ class Account( torrent = root, directMentions = directMentions, geohash = geohash, - nip94attachments = nip94attachments, + imetas = imetas, forkedFrom = forkedFrom, signer = signer, isDraft = draftTag != null, @@ -2113,19 +2280,19 @@ class Account( } } } else { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) LocalCache.justConsume(it, null) // broadcast replied notes replyingTo?.let { LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) } } - replyTo?.forEach { it.event?.let { 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 { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) } } } @@ -2138,7 +2305,7 @@ class Account( val noteEvent = note.event if (noteEvent is DraftEvent) { noteEvent.createDeletedEvent(signer) { - Client.sendPrivately( + Amethyst.instance.client.sendPrivately( it, note.relays.map { it.url }.map { RelaySetupInfoToConnect( @@ -2157,6 +2324,352 @@ class Account( } } + suspend fun sendReplyComment( + message: String, + replyingTo: Note, + directMentionsUsers: Set = emptySet(), + directMentionsNotes: Set = emptySet(), + imetas: 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.author?.pubkeyHex, note.relayHintUrl()) + } else { + null + } + } + + if (replyingTo.event is CommentEvent) { + CommentEvent.replyComment( + msg = message, + replyingTo = EventHint(replyingTo.event as CommentEvent, replyingTo.relayHintUrl()), + usersMentioned = usersMentioned, + addressesMentioned = addressesMentioned, + eventsMentioned = eventsMentioned, + imetas = imetas, + 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 = EventHint(replyingTo.event as Event, replyingTo.relayHintUrl()), + usersMentioned = usersMentioned, + addressesMentioned = addressesMentioned, + eventsMentioned = eventsMentioned, + imetas = imetas, + 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, + 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.author?.pubkeyHex, note.relayHintUrl()) + } else { + null + } + } + + if (replyingTo != null) { + CommentEvent.replyComment( + msg = message, + replyingTo = EventHint(replyingTo.event as CommentEvent, replyingTo.relayHintUrl()), + 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) + + 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?, + readingScene: InteractiveStoryBaseEvent, + readingSceneRelay: String?, + ) { + if (!isWriteable()) return + + val relayList = getPrivateOutBoxRelayList() + + InteractiveStoryReadingStateEvent.create( + root = root, + rootRelay = rootRelay, + currentScene = readingScene, + currentSceneRelay = readingSceneRelay, + signer = signer, + ) { + if (relayList.isNotEmpty()) { + Amethyst.instance.client.sendPrivately(it, relayList = relayList) + } else { + Amethyst.instance.client.send(it) + } + LocalCache.justConsume(it, null) + } + } + + suspend fun updateInteractiveStoryReadingState( + readingState: InteractiveStoryReadingStateEvent, + readingScene: InteractiveStoryBaseEvent, + readingSceneRelay: String?, + ) { + if (!isWriteable()) return + + val relayList = getPrivateOutBoxRelayList() + + InteractiveStoryReadingStateEvent.update( + base = readingState, + currentScene = readingScene, + currentSceneRelay = readingSceneRelay, + signer = signer, + ) { + if (relayList.isNotEmpty()) { + Amethyst.instance.client.sendPrivately(it, relayList = relayList) + } else { + Amethyst.instance.client.send(it) + } + LocalCache.justConsume(it, null) + } + } + + suspend fun sendInteractiveStoryPrologue( + baseId: String, + title: String, + content: String, + options: List, + summary: String? = null, + image: String? = null, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + imetas: List? = null, + draftTag: String? = null, + relayList: List, + ) { + 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) + } + } + } + + suspend fun sendInteractiveStoryScene( + baseId: String, + title: String, + content: String, + options: List, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + imetas: List? = null, + draftTag: String? = null, + relayList: List, + ) { + 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) + } + } + } + suspend fun sendPost( message: String, replyTo: List?, @@ -2171,7 +2684,7 @@ class Account( forkedFrom: Event?, relayList: List, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String?, ) { if (!isWriteable()) return @@ -2193,7 +2706,7 @@ class Account( root = root, directMentions = directMentions, geohash = geohash, - nip94attachments = nip94attachments, + imetas = imetas, forkedFrom = forkedFrom, signer = signer, isDraft = draftTag != null, @@ -2207,19 +2720,19 @@ class Account( } } } else { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) LocalCache.justConsume(it, null) // broadcast replied notes replyingTo?.let { LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) } } - replyTo?.forEach { it.event?.let { 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 { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) } } } @@ -2245,7 +2758,7 @@ class Account( signer = signer, ) { LocalCache.justConsume(it, null) - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) } } @@ -2263,7 +2776,7 @@ class Account( zapRaiserAmount: Long? = null, relayList: List, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String?, ) { if (!isWriteable()) return @@ -2287,7 +2800,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, - nip94attachments = nip94attachments, + imetas = imetas, isDraft = draftTag != null, ) { if (draftTag != null) { @@ -2299,14 +2812,14 @@ class Account( } } } else { - Client.send(it, relayList = relayList) + 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 { 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 { - Client.send(it, relayList = relayList) + Amethyst.instance.client.send(it, relayList = relayList) } } } @@ -2321,8 +2834,9 @@ class Account( zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, + directMentions: Set, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String?, ) { if (!isWriteable()) return @@ -2338,8 +2852,9 @@ class Account( zapReceiver = zapReceiver, markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, + directMentions = directMentions, geohash = geohash, - nip94attachments = nip94attachments, + imetas = imetas, signer = signer, isDraft = draftTag != null, ) { @@ -2352,7 +2867,7 @@ class Account( } } } else { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -2367,7 +2882,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String?, ) { if (!isWriteable()) return @@ -2385,7 +2900,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, - nip94attachments = nip94attachments, + imetas = imetas, signer = signer, isDraft = draftTag != null, ) { @@ -2398,7 +2913,7 @@ class Account( } } } else { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -2413,7 +2928,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String?, ) { sendPrivateMessage( @@ -2425,7 +2940,7 @@ class Account( wantsToMarkAsSensitive, zapRaiserAmount, geohash, - nip94attachments, + imetas, draftTag, ) } @@ -2439,7 +2954,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String?, ) { if (!isWriteable()) return @@ -2457,7 +2972,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, - nip94attachments = nip94attachments, + imetas = imetas, signer = signer, advertiseNip18 = false, isDraft = draftTag != null, @@ -2471,12 +2986,54 @@ class Account( } } } else { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it, null) } } } + 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?, + ) { + 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, + ) { + broadcastPrivately(it) + } + } + fun sendNIP17PrivateMessage( message: String, toUsers: List, @@ -2487,7 +3044,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String? = null, ) { if (!isWriteable()) return @@ -2505,7 +3062,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, - nip94attachments = nip94attachments, + imetas = imetas, draftTag = draftTag, signer = signer, ) { @@ -2523,22 +3080,23 @@ class Account( } } - fun sendDraftEvent(draftEvent: DraftEvent) { - val relayList = - normalizedPrivateOutBoxRelaySet.value.map { - RelaySetupInfoToConnect( - it, - shouldUseTorForClean(it), - true, - true, - emptySet(), - ) - } + fun getPrivateOutBoxRelayList(): List = + normalizedPrivateOutBoxRelaySet.value.map { + RelaySetupInfoToConnect( + it, + shouldUseTorForClean(it), + true, + true, + emptySet(), + ) + } + fun sendDraftEvent(draftEvent: DraftEvent) { + val relayList = getPrivateOutBoxRelayList() if (relayList.isNotEmpty()) { - Client.sendPrivately(draftEvent, relayList) + Amethyst.instance.client.sendPrivately(draftEvent, relayList) } else { - Client.send(draftEvent) + Amethyst.instance.client.send(draftEvent) } LocalCache.justConsume(draftEvent, null) } @@ -2588,12 +3146,12 @@ class Account( } if (relayList != null) { - Client.sendPrivately(signedEvent = wrap, relayList = relayList) + Amethyst.instance.client.sendPrivately(signedEvent = wrap, relayList = relayList) } else { - Client.send(wrap) + Amethyst.instance.client.send(wrap) } } else { - Client.send(wrap) + Amethyst.instance.client.send(wrap) } } } @@ -2611,7 +3169,7 @@ class Account( picture = picture, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) LocalCache.getChannelIfExists(it.id)?.let { follow(it) } @@ -2626,7 +3184,7 @@ class Account( val oldEvent = oldStatus.event as? StatusEvent ?: return StatusEvent.update(oldEvent, newStatus, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -2635,7 +3193,7 @@ class Account( if (!isWriteable()) return StatusEvent.create(newStatus, "general", expiration = null, signer) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -2645,11 +3203,11 @@ class Account( val oldEvent = oldStatus.event as? StatusEvent ?: return StatusEvent.clear(oldEvent, signer) { event -> - Client.send(event) + Amethyst.instance.client.send(event) LocalCache.justConsume(event, null) DeletionEvent.createForVersionOnly(listOf(event), signer) { event2 -> - Client.send(event2) + Amethyst.instance.client.send(event2) LocalCache.justConsume(event2, null) } } @@ -2670,7 +3228,7 @@ class Account( noteEvent.taggedAddresses().filter { it != emojiListEvent.address() }, signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -2688,7 +3246,7 @@ class Account( listOf(emojiListEvent.address()), signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -2703,7 +3261,7 @@ class Account( noteEvent.taggedAddresses().plus(emojiListEvent.address()), signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -2714,7 +3272,7 @@ class Account( url: String, relay: String?, blurhash: String?, - dim: String?, + dim: Dimension?, hash: String?, mimeType: String?, ) { @@ -2734,7 +3292,7 @@ class Account( originalHash = originalHash, */ signer = signer, ) { event -> - Client.send(event) + Amethyst.instance.client.send(event) LocalCache.consume(event, null) } } @@ -2757,7 +3315,7 @@ class Account( isPrivate, signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it) } } else { @@ -2767,7 +3325,7 @@ class Account( isPrivate, signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it) } } @@ -2788,7 +3346,7 @@ class Account( isPrivate, signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it) } } else { @@ -2798,17 +3356,20 @@ class Account( isPrivate, signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it) } } } - fun createAuthEvent( + fun sendAuthEvent( relay: Relay, challenge: String, - onReady: (RelayAuthEvent) -> Unit, - ) = createAuthEvent(relay.url, challenge, onReady = onReady) + ) { + createAuthEvent(relay.url, challenge) { + Amethyst.instance.client.sendIfExists(it, relay) + } + } fun createAuthEvent( relayUrl: String, @@ -2925,7 +3486,7 @@ class Account( isPrivate = true, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it, null) } } else { @@ -2934,7 +3495,7 @@ class Account( isPrivate = true, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it, null) } } @@ -2950,7 +3511,7 @@ class Account( isPrivate = true, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it, null) } } @@ -2964,7 +3525,7 @@ class Account( isPrivate = true, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it, null) } } @@ -2980,7 +3541,7 @@ class Account( isPrivate = true, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it, null) } } else { @@ -2989,7 +3550,7 @@ class Account( isPrivate = true, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it, null) } } @@ -3005,7 +3566,7 @@ class Account( isPrivate = true, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it, null) } } @@ -3019,7 +3580,7 @@ class Account( isPrivate = true, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.consume(it, null) } } @@ -3049,7 +3610,7 @@ class Account( originalChannelIdHex = channel.idHex, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) follow(channel) @@ -3079,9 +3640,9 @@ class Account( } if (relayList != null) { - Client.sendPrivately(it, relayList) + Amethyst.instance.client.sendPrivately(it, relayList) } else { - Client.send(it) + Amethyst.instance.client.send(it) } LocalCache.justConsume(it, null) onReady(it) @@ -3296,7 +3857,7 @@ class Account( relays = dmRelays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -3304,7 +3865,7 @@ class Account( relays = dmRelays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -3330,7 +3891,7 @@ class Account( relays = relays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -3338,7 +3899,7 @@ class Account( relays = relays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -3364,7 +3925,7 @@ class Account( relays = searchRelays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -3372,7 +3933,7 @@ class Account( relays = searchRelays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -3398,7 +3959,7 @@ class Account( relays = relays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -3406,7 +3967,7 @@ class Account( relays = relays, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } @@ -3418,6 +3979,31 @@ class Account( fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddressATag(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 host(url: String): String = + try { + URIReference.parse(url).host.value + } catch (e: Exception) { + url + } + + fun mergeServerList( + nip96: FileServersEvent?, + blossom: BlossomServersEvent?, + ): List { + val nip96servers = nip96?.servers()?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList() + val blossomServers = blossom?.servers()?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() + + val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS } + + return result + ServerName("NIP95", "", ServerType.NIP95) + } + fun sendFileServersList(servers: List) { if (!isWriteable()) return @@ -3429,7 +4015,7 @@ class Account( relays = servers, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } else { @@ -3437,7 +4023,32 @@ class Account( relays = servers, signer = signer, ) { - Client.send(it) + Amethyst.instance.client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun sendBlossomServersList(servers: List) { + if (!isWriteable()) return + + val serverList = getBlossomServersList() + + if (serverList != null && serverList.tags.isNotEmpty()) { + BlossomServersEvent.updateRelayList( + earlierVersion = serverList, + relays = servers, + signer = signer, + ) { + Amethyst.instance.client.send(it) + LocalCache.justConsume(it, null) + } + } else { + BlossomServersEvent.createFromScratch( + relays = servers, + signer = signer, + ) { + Amethyst.instance.client.send(it) LocalCache.justConsume(it, null) } } 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 b767838f8..ebc5044c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -20,8 +20,11 @@ */ package com.vitorpamplona.amethyst.model +import android.util.Log import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.service.Nip96MediaServers +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.ammolite.relays.Constants @@ -96,7 +99,7 @@ class AccountSettings( var externalSignerPackageName: String? = null, var localRelays: Set = Constants.defaultRelays.toSet(), var localRelayServers: Set = setOf(), - var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], + var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), @@ -120,7 +123,6 @@ class AccountSettings( val pendingAttestations: MutableStateFlow> = MutableStateFlow>(mapOf()), ) { val saveable = MutableStateFlow(AccountSettingsUpdater(this)) - val syncedSettings: AccountSyncedSettings = backupSyncedSettings?.let { AccountSyncedSettings(it) } ?: AccountSyncedSettings(AccountSyncedSettingsInternal()) @@ -141,7 +143,19 @@ class AccountSettings( } else { when (val packageName = externalSignerPackageName) { null -> NostrSignerInternal(keyPair) - else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName)) + else -> { + val externalSignerLauncher = ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName) + // TODO: How to handle the launcher here? + try { + externalSignerLauncher.registerLauncher( + launcher = { }, + contentResolver = Amethyst.instance::contentResolverFn, + ) + } catch (e: Exception) { + Log.d("AccountSettings", "Failed to initialize external signer", e) + } + NostrSignerExternal(keyPair.pubKey.toHexKey(), externalSignerLauncher) + } } } @@ -189,7 +203,7 @@ class AccountSettings( // file servers // --- - fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) { + fun changeDefaultFileServer(server: ServerName) { if (defaultFileServer != server) { defaultFileServer = server saveAccountSettings() 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 b0b8a3fe4..7bc7ec22e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.toNote +import com.vitorpamplona.quartz.encoders.toNEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import kotlinx.coroutines.Dispatchers @@ -107,7 +107,7 @@ abstract class Channel( open fun id() = Hex.decode(idHex) - open fun idNote() = id().toNote() + open fun idNote() = id().toNEvent() open fun idDisplayNote() = idNote().toShortenHex() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 29877f938..11a5dc813 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.Coffee import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Footstr +import com.vitorpamplona.amethyst.commons.hashtags.Gamestr import com.vitorpamplona.amethyst.commons.hashtags.Grownostr import com.vitorpamplona.amethyst.commons.hashtags.Lightning import com.vitorpamplona.amethyst.commons.hashtags.Mate @@ -56,7 +57,7 @@ import com.vitorpamplona.quartz.events.EmptyTagList fun RenderHashTagIconsPreview() { ThemeComparisonColumn { RenderRegular( - "Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate", + "Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain", EmptyTagList, ) { word, state -> when (word) { @@ -83,6 +84,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? = "tunestr", "music", "nowplaying" -> tunestr "mate", "matechain", "matestr" -> matestr "weed", "weedstr", "420", "cannabis", "marijuana" -> weed + "gamestr", "gaming", "gamechain" -> gamestr else -> null } @@ -100,6 +102,7 @@ val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.paddin val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp)) val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp)) val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp)) +val gamestr = HashtagIcon(CustomHashTagIcons.Gamestr, "GameStr", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp)) @Immutable class HashtagIcon( 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 da9375ebb..312950edf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.BadgeDefinitionEvent import com.vitorpamplona.quartz.events.BadgeProfilesEvent import com.vitorpamplona.quartz.events.BaseAddressableEvent +import com.vitorpamplona.quartz.events.BaseTextNoteEvent +import com.vitorpamplona.quartz.events.BlossomServersEvent import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.CalendarDateSlotEvent import com.vitorpamplona.quartz.events.CalendarEvent @@ -58,10 +60,12 @@ import com.vitorpamplona.quartz.events.ChannelListEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMuteUserEvent +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityListEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent @@ -83,6 +87,9 @@ import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LnZapEvent @@ -100,6 +107,7 @@ import com.vitorpamplona.quartz.events.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.events.NNSEvent import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -107,6 +115,7 @@ import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RecommendRelayEvent +import com.vitorpamplona.quartz.events.RelationshipStatusEvent import com.vitorpamplona.quartz.events.RelaySetEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.RepostEvent @@ -438,61 +447,30 @@ object LocalCache { fun consume( event: TextNoteEvent, relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: TorrentEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( - event: TorrentCommentEvent, + event: InteractiveStoryPrologueEvent, + relay: Relay?, + ) = consumeBaseReplaceable(event, relay) + + fun consume( + event: InteractiveStorySceneEvent, + relay: Relay?, + ) = consumeBaseReplaceable(event, relay) + + fun consume( + event: InteractiveStoryReadingStateEvent, + relay: Relay?, + ) = consumeBaseReplaceable(event, relay) + + fun consumeRegularEvent( + event: Event, relay: Relay?, ) { val note = getOrCreateNote(event.id) @@ -506,242 +484,69 @@ object LocalCache { // Already processed this event. if (note.event != null) return - if (antiSpam.isSpam(event, relay)) { + val replyTo = computeReplyTo(event) + + if (event is BaseTextNoteEvent && antiSpam.isSpam(event, relay)) { return } - val replyTo = computeReplyTo(event) - note.loadEvent(event, author, replyTo) // Counts the replies - replyTo.forEach { - it.addReply(note) - } + replyTo.forEach { it.addReply(note) } refreshObservers(note) } + fun consume( + event: PictureEvent, + relay: Relay?, + ) = consumeRegularEvent(event, relay) + + fun consume( + event: TorrentCommentEvent, + relay: Relay?, + ) = consumeRegularEvent(event, relay) + fun consume( event: NIP90ContentDiscoveryResponseEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - // Log.d("TN", "New Response ${event.taggedEvents().joinToString(", ") { it }}}") - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: NIP90ContentDiscoveryRequestEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: NIP90StatusEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: NIP90UserDiscoveryResponseEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: NIP90UserDiscoveryRequestEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: GitPatchEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: GitIssueEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: GitReplyEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - val replyTo = computeReplyTo(event) - - // println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}") - - note.loadEvent(event, author, replyTo) - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: LongTextNoteEvent, @@ -818,7 +623,11 @@ object LocalCache { is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is GitReplyEvent -> event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) } is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + is LnZapEvent -> event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + @@ -866,35 +675,8 @@ object LocalCache { fun consume( event: PollNoteEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) private fun consume( event: LiveActivitiesEvent, @@ -954,6 +736,13 @@ object LocalCache { consumeBaseReplaceable(event, relay) } + fun consume( + event: BlossomServersEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + fun consume( event: FileServersEvent, relay: Relay?, @@ -1084,6 +873,13 @@ object LocalCache { } } + fun consume( + event: RelationshipStatusEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + fun consume( event: OtsEvent, relay: Relay?, @@ -1134,25 +930,10 @@ object LocalCache { } } - fun consume(event: BadgeAwardEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") - - val author = getOrCreateUser(event.pubKey) - val awardDefinition = computeReplyTo(event) - - note.loadEvent(event, author, awardDefinition) - - // Replies of an Badge Definition are Award Events - awardDefinition.forEach { it.addReply(note) } - - refreshObservers(note) - } + fun consume( + event: BadgeAwardEvent, + relay: Relay?, + ) = consumeRegularEvent(event, relay) private fun comsume( event: NNSEvent, @@ -1632,6 +1413,11 @@ object LocalCache { refreshObservers(note) } + fun consume( + event: CommentEvent, + relay: Relay?, + ) = consumeRegularEvent(event, relay) + fun consume( event: LiveActivitiesChatMessageEvent, relay: Relay?, @@ -1727,102 +1513,27 @@ object LocalCache { fun consume( event: AudioHeaderEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: FileHeaderEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: ProfileGalleryEntryEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: FileStorageHeaderEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: FhirResourceEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: TextNoteModificationEvent, @@ -1855,22 +1566,7 @@ object LocalCache { fun consume( event: HighlightEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: FileStorageEvent, @@ -1928,7 +1624,50 @@ object LocalCache { // Already processed this event. if (note.event != null) return - val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet() + val recipientsHex = event.groupMembers() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + + // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") + + val repliesTo = computeReplyTo(event) + + note.loadEvent(event, author, repliesTo) + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.addMessage(authorGroup, note) + } + } + + refreshObservers(note) + } + + private fun consume( + event: ChatMessageEncryptedFileHeaderEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + val recipientsHex = event.groupMembers() val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") @@ -2058,14 +1797,24 @@ object LocalCache { } } - return users.filter { _, user: User -> - ( - (user.anyNameStartsWith(username)) || - user.pubkeyHex.startsWith(username, true) || - user.pubkeyNpub().startsWith(username, true) - ) && - (forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.flowHiddenUsers.value.hiddenWordsCase))) - } + val finds = + users.filter { _, user: User -> + ( + (user.anyNameStartsWith(username)) || + user.pubkeyHex.startsWith(username, true) || + user.pubkeyNpub().startsWith(username, true) + ) && + (forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.flowHiddenUsers.value.hiddenWordsCase))) + } + + return finds.sortedWith( + compareBy( + { forAccount?.isFollowing(it) == false }, + { !it.toBestDisplayName().startsWith(username, ignoreCase = true) }, + { it.toBestDisplayName().lowercase() }, + { it.pubkeyHex }, + ), + ) } fun findNotesStartingWith( @@ -2095,8 +1844,7 @@ object LocalCache { } if (note.event?.matchTag1With(text) == true || - note.idHex.startsWith(text, true) || - note.idNote().startsWith(text, true) + note.idHex.startsWith(text, true) ) { if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) { return@filter true @@ -2575,7 +2323,27 @@ object LocalCache { } } is ChatMessageEvent -> { - val recipientsHex = draft.recipientsPubKey().plus(draftWrap.pubKey).toSet() + val recipientsHex = draft.groupMembers() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.addMessage(authorGroup, note) + } + } + } + is ChatMessageEncryptedFileHeaderEvent -> { + val recipientsHex = draft.groupMembers() val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() if (recipients.isNotEmpty()) { @@ -2643,6 +2411,26 @@ object LocalCache { } } } + is ChatMessageEncryptedFileHeaderEvent -> { + val recipientsHex = draft.groupMembers() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.removeMessage(authorGroup, draftWrap) + } + } + } is ChannelMessageEvent -> { draft.channel()?.let { channelId -> checkGetOrCreateChannel(channelId)?.let { channel -> @@ -2694,9 +2482,10 @@ object LocalCache { is AppSpecificDataEvent -> consume(event, relay) is AudioHeaderEvent -> consume(event, relay) is AudioTrackEvent -> consume(event, relay) - is BadgeAwardEvent -> consume(event) + is BadgeAwardEvent -> consume(event, relay) is BadgeDefinitionEvent -> consume(event, relay) is BadgeProfilesEvent -> consume(event) + is BlossomServersEvent -> consume(event, relay) is BookmarkListEvent -> consume(event) is CalendarEvent -> consume(event, relay) is CalendarDateSlotEvent -> consume(event, relay) @@ -2708,9 +2497,11 @@ object LocalCache { is ChannelMessageEvent -> consume(event, relay) is ChannelMetadataEvent -> consume(event) is ChannelMuteUserEvent -> consume(event) + is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay) is ChatMessageEvent -> consume(event, relay) is ChatMessageRelayListEvent -> consume(event, relay) is ClassifiedsEvent -> consume(event, relay) + is CommentEvent -> consume(event, relay) is CommunityDefinitionEvent -> consume(event, relay) is CommunityListEvent -> consume(event, relay) is CommunityPostApprovalEvent -> { @@ -2738,6 +2529,9 @@ object LocalCache { is GitPatchEvent -> consume(event, relay) is GitRepositoryEvent -> consume(event, relay) is HighlightEvent -> consume(event, relay) + is InteractiveStoryPrologueEvent -> consume(event, relay) + is InteractiveStorySceneEvent -> consume(event, relay) + is InteractiveStoryReadingStateEvent -> consume(event, relay) is LiveActivitiesEvent -> consume(event, relay) is LiveActivitiesChatMessageEvent -> consume(event, relay) is LnZapEvent -> { @@ -2760,6 +2554,7 @@ object LocalCache { is MuteListEvent -> consume(event, relay) is NNSEvent -> comsume(event, relay) is OtsEvent -> consume(event, relay) + is PictureEvent -> consume(event, relay) is PrivateDmEvent -> consume(event, relay) is PrivateOutboxRelayListEvent -> consume(event, relay) is PinListEvent -> consume(event, relay) @@ -2767,6 +2562,7 @@ object LocalCache { is PollNoteEvent -> consume(event, relay) is ReactionEvent -> consume(event) is RecommendRelayEvent -> consume(event) + is RelationshipStatusEvent -> consume(event, relay) is RelaySetEvent -> consume(event, relay) is ReportEvent -> consume(event, relay) is RepostEvent -> { 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 bb88bf0b1..5e05d37dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -25,9 +25,11 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.distinctUntilChanged +import com.vitorpamplona.amethyst.launchAndWaitAll import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji +import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.amethyst.ui.actions.relays.updated import com.vitorpamplona.amethyst.ui.note.combineWith import com.vitorpamplona.amethyst.ui.note.toShortenHex @@ -40,7 +42,6 @@ import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.LnInvoiceUtil import com.vitorpamplona.quartz.encoders.Nip19Bech32 -import com.vitorpamplona.quartz.encoders.toNote import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent @@ -70,18 +71,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeoutOrNull import java.math.BigDecimal +import kotlin.coroutines.Continuation import kotlin.coroutines.resume @Stable class AddressableNote( val address: ATag, ) : Note(address.toTag()) { - override fun idNote() = address.toNAddr() + override fun idNote() = address.toNAddr(relayHintUrl()) - override fun toNEvent() = address.toNAddr() + override fun toNEvent() = address.toNAddr(relayHintUrl()) override fun idDisplayNote() = idNote().toShortenHex() @@ -96,7 +96,7 @@ class AddressableNote( return minOf(publishedAt, lastCreatedAt) } - fun dTag(): String? = (event as? AddressableEvent)?.dTag() + fun dTag(): String = address.dTag override fun wasOrShouldBeDeletedBy( deletionEvents: Set, @@ -145,7 +145,7 @@ open class Note( fun id() = Hex.decode(idHex) - open fun idNote() = id().toNote() + open fun idNote() = toNEvent() open fun toNEvent(): String { val myEvent = event @@ -156,13 +156,27 @@ open class Note( host.id, host.pubKey, host.kind, - relays.firstOrNull()?.url, + relayHintUrl(), ) } else { - Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) + Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl()) } } else { - Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) + Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl()) + } + } + + fun relayHintUrl(): String? { + val authorRelay = author?.latestMetadataRelay + + return if (relays.isNotEmpty()) { + if (authorRelay != null && relays.any { it.url == authorRelay }) { + authorRelay + } else { + relays.firstOrNull()?.url + } + } else { + null } } @@ -423,31 +437,36 @@ open class Note( } } - private fun recursiveIsPaidByCalculation( + private suspend fun isPaidByCalculation( account: Account, - remainingZapPayments: List>, + zapEvents: List>, onWasZappedByAuthor: () -> Unit, ) { - if (remainingZapPayments.isEmpty()) { + if (zapEvents.isEmpty()) { return } - val next = remainingZapPayments.first() + var hasSentOne = false - val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent - if (zapResponseEvent != null) { - account.decryptZapPaymentResponseEvent(zapResponseEvent) { response -> - if ( - response is PayInvoiceSuccessResponse && - account.isNIP47Author(zapResponseEvent.requestAuthor()) - ) { + launchAndWaitAll(zapEvents) { next -> + val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent + + if (zapResponseEvent != null) { + val result = + tryAndWait { continuation -> + account.decryptZapPaymentResponseEvent(zapResponseEvent) { response -> + if ( + response is PayInvoiceSuccessResponse && + account.isNIP47Author(zapResponseEvent.requestAuthor()) + ) { + continuation.resume(true) + } + } + } + + if (!hasSentOne && result == true) { + hasSentOne = true onWasZappedByAuthor() - } else { - recursiveIsPaidByCalculation( - account, - remainingZapPayments.minus(next), - onWasZappedByAuthor, - ) } } } @@ -457,14 +476,14 @@ open class Note( option: Int?, user: User, account: Account, - remainingZapEvents: Map, + zapEvents: Map, onWasZappedByAuthor: () -> Unit, ) { - if (remainingZapEvents.isEmpty()) { + if (zapEvents.isEmpty()) { return } - remainingZapEvents.forEach { next -> + zapEvents.forEach { next -> val zapRequest = next.key.event as LnZapRequestEvent val zapEvent = next.value?.event as? LnZapEvent @@ -487,11 +506,9 @@ open class Note( } else { if (account.isWriteable()) { val result = - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - zapRequest.decryptPrivateZap(account.signer) { - continuation.resume(it) - } + tryAndWait { continuation -> + zapRequest.decryptPrivateZap(account.signer) { + continuation.resume(it) } } @@ -512,7 +529,7 @@ open class Note( ) { isZappedByCalculation(null, user, account, zaps, onWasZappedByAuthor) if (account.userProfile() == user) { - recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor) + isPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor) } } @@ -564,23 +581,79 @@ open class Note( zapsAmount = sumOfAmounts } - private fun recursiveZappedAmountCalculation( - invoiceSet: LinkedHashSet, - remainingZapPayments: List>, + private suspend fun zappedAmountCalculation( + startAmount: BigDecimal, + paidInvoiceSet: LinkedHashSet, + zapPayments: List>, signer: NostrSigner, - output: BigDecimal, onReady: (BigDecimal) -> Unit, ) { - if (remainingZapPayments.isEmpty()) { - onReady(output) + if (zapPayments.isEmpty()) { + onReady(startAmount) return } - val next = remainingZapPayments.first() + var output: BigDecimal = startAmount - (next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent -> + launchAndWaitAll(zapPayments) { next -> + val result = + tryAndWait { continuation -> + processZapAmountFromResponse( + next.first, + next.second, + continuation, + signer, + ) + } + + if (result != null && !paidInvoiceSet.contains(result.invoice)) { + paidInvoiceSet.add(result.invoice) + output = output.add(result.amount) + } + } + + onReady(output) + } + + private fun processZapAmountFromResponse( + paymentRequest: Note, + paymentResponse: Note?, + continuation: Continuation, + signer: NostrSigner, + ) { + val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent + val nwcResponse = paymentResponse?.event as? LnZapPaymentResponseEvent + + if (nwcRequest != null && nwcResponse != null) { + processZapAmountFromResponse( + nwcRequest, + nwcResponse, + continuation, + signer, + ) + } else { + continuation.resume(null) + } + } + + class InvoiceAmount( + val invoice: String, + val amount: BigDecimal, + ) + + private fun processZapAmountFromResponse( + nwcRequest: LnZapPaymentRequestEvent, + nwcResponse: LnZapPaymentResponseEvent, + continuation: Continuation, + signer: NostrSigner, + ) { + // if we can decrypt the reply + nwcResponse.response(signer) { noteEvent -> + // if it is a sucess if (noteEvent is PayInvoiceSuccessResponse) { - (next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice -> + // if we can decrypt the invoice + nwcRequest.lnInvoice(signer) { invoice -> + // if we can parse the amount val amount = try { LnInvoiceUtil.getAmountInSats(invoice) @@ -589,26 +662,20 @@ open class Note( null } - var newAmount = output - - if (amount != null && !invoiceSet.contains(invoice)) { - invoiceSet.add(invoice) - newAmount += amount + // avoid double counting + if (amount != null) { + continuation.resume(InvoiceAmount(invoice, amount)) + } else { + continuation.resume(null) } - - recursiveZappedAmountCalculation( - invoiceSet, - remainingZapPayments.minus(next), - signer, - newAmount, - onReady, - ) } + } else { + continuation.resume(null) } } } - fun zappedAmountWithNWCPayments( + suspend fun zappedAmountWithNWCPayments( signer: NostrSigner, onReady: (BigDecimal) -> Unit, ) { @@ -619,11 +686,11 @@ open class Note( val invoiceSet = LinkedHashSet(zaps.size + zapPayments.size) zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } } - recursiveZappedAmountCalculation( + zappedAmountCalculation( + zapsAmount, invoiceSet, zapPayments.toList(), signer, - zapsAmount, onReady, ) } 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 8694a0825..dfad0c60b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.amethyst.model +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.RepostEvent -import kotlin.time.measureTimedValue +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableSet class ThreadAssembler { private fun searchRoot( @@ -71,32 +73,50 @@ class ThreadAssembler { return null } - fun findThreadFor(noteId: String): Set { + @Stable + class ThreadInfo( + val root: Note, + val allNotes: ImmutableSet, + ) + + fun findThreadFor(noteId: String): ThreadInfo? { checkNotInMainThread() - val (result, elapsed) = - measureTimedValue { - val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet() + val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null - if (note.event != null) { - val thread = OnlyLatestVersionSet() + return if (note.event != null) { + val thread = OnlyLatestVersionSet() - val threadRoot = searchRoot(note, thread) ?: note + val threadRoot = searchRoot(note, thread) ?: note - loadDown(threadRoot, thread) - // adds the replies of the note in case the search for Root - // did not added them. - note.replies.forEach { loadDown(it, thread) } + loadUp(note, thread) - thread - } else { - setOf(note) - } - } + loadDown(threadRoot, thread) + // adds the replies of the note in case the search for Root + // did not added them. + note.replies.forEach { loadDown(it, thread) } - println("Model Refresh: Thread loaded in $elapsed") + ThreadInfo( + root = note, + allNotes = thread.toImmutableSet(), + ) + } else { + ThreadInfo( + root = note, + allNotes = setOf(note).toImmutableSet(), + ) + } + } - return result + fun loadUp( + note: Note, + thread: MutableSet, + ) { + if (note !in thread) { + thread.add(note) + + note.replyTo?.forEach { loadUp(it, thread) } + } } fun loadDown( 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 209b50cec..df804b071 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.RepostEvent +import java.lang.Long.min import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -54,13 +55,23 @@ object ThreadLevelCalculator { now: Long, ): LevelSignature { val replyTo = note.replyTo + + // estimates the min date by replies if it doesn't exist. + val createdAt = + note.createdAt() ?: min( + note.replies.minOfOrNull { it.createdAt() ?: now } ?: now, + note.reactions.values.minOfOrNull { it.minOfOrNull { it.createdAt() ?: now } ?: now } ?: now, + ) + + val noteAuthor = note.author + if ( note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty() ) { return LevelSignature( - signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";", - createdAt = note.createdAt(), - author = note.author, + signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";", + createdAt = createdAt, + author = noteAuthor, ) } @@ -86,23 +97,21 @@ object ThreadLevelCalculator { val parentSignature = parent?.signature?.removeSuffix(";") ?: "" val threadOrder = - if (parent?.author == note.author && note.createdAt() != null) { + if (noteAuthor != null && parent?.author == noteAuthor) { // author of the thread first, in **ascending** order - "9" + - formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) + - note.idHex.substring(0, 8) - } else if (note.author?.pubkeyHex == account.pubkeyHex) { - "8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies - } else if (note.author?.pubkeyHex in accountFollowingSet) { - "7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies. + "9" + formattedDateTime((parent.createdAt ?: 0) + (now - createdAt)) + note.idHex.substring(0, 8) + } else if (noteAuthor != null && noteAuthor.pubkeyHex == account.pubkeyHex) { + "8" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my replies + } else if (noteAuthor != null && noteAuthor.pubkeyHex in accountFollowingSet) { + "7" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my follows replies. } else { - "0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else. + "0" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // everyone else. } val mySignature = LevelSignature( - signature = parentSignature + "/" + threadOrder + ";", - createdAt = note.createdAt(), + signature = "$parentSignature/$threadOrder;", + createdAt = createdAt, author = note.author, ) 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 344dfd5c9..0bf31c790 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -340,19 +340,9 @@ class User( fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false - fun isFollowingHashtag(tag: String): Boolean { - return latestContactList?.unverifiedFollowTagSet()?.map { it.lowercase() }?.toSet()?.let { - return tag.lowercase() in it - } - ?: false - } + fun isFollowingHashtag(tag: String) = latestContactList?.isTaggedHash(tag) ?: false - fun isFollowingGeohash(geoTag: String): Boolean { - return latestContactList?.unverifiedFollowAddressSet()?.toSet()?.let { - return geoTag.lowercase() in it - } - ?: false - } + fun isFollowingGeohash(geoTag: String) = latestContactList?.isTaggedGeoHash(geoTag) ?: false fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size @@ -400,6 +390,10 @@ class User( return true } + if (info?.nip05?.containsAny(hiddenWordsCase) == true) { + return true + } + return false } 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 99e7d12b2..e9fbdb558 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.ammolite.relays.NostrDataSource import com.vitorpamplona.ammolite.relays.Relay @@ -28,7 +29,7 @@ import com.vitorpamplona.quartz.events.Event abstract class AmethystNostrDataSource( debugName: String, -) : NostrDataSource(debugName) { +) : NostrDataSource(Amethyst.instance.client, debugName) { override fun consume( event: Event, relay: 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 8590b1ff9..eea91d393 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt @@ -22,17 +22,20 @@ package com.vitorpamplona.amethyst.service import android.content.Context import android.graphics.BitmapFactory -import android.net.Uri import androidx.compose.runtime.Stable import coil3.ImageLoader +import coil3.Uri import coil3.asImage import coil3.decode.DataSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.ImageFetchResult +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.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.toHexKey import java.util.Base64 @Stable @@ -66,13 +69,24 @@ class Base64Fetcher( data: Uri, options: Options, imageLoader: ImageLoader, - ): Fetcher? { - return if (base64contentPattern.matcher(data.toString()).find()) { - return Base64Fetcher(options, data) + ): Fetcher? = + if (data.scheme == "data") { + Base64Fetcher(options, data) + } else { + null + } + } + + object BKeyer : Keyer { + override fun key( + data: Uri, + options: Options, + ): String? = + if (data.scheme == "data") { + CryptoUtils.sha256(data.toString().toByteArray()).toHexKey() } else { null } - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt index 43ea68a86..96cc3044a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.service -import android.content.Context -import android.net.Uri import androidx.compose.runtime.Stable import coil3.ImageLoader import coil3.asImage @@ -29,23 +27,25 @@ import coil3.decode.DataSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.ImageFetchResult -import coil3.request.ImageRequest +import coil3.key.Keyer import coil3.request.Options -import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder -import java.net.URLDecoder -import java.net.URLEncoder +import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder + +class Blurhash( + val blurhash: String, +) @Stable class BlurHashFetcher( private val options: Options, - private val data: Uri, + private val data: Blurhash, ) : Fetcher { override suspend fun fetch(): FetchResult { checkNotInMainThread() - val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8") + val hash = data.blurhash - val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data") + val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data") return ImageFetchResult( image = bitmap.asImage(true), @@ -54,26 +54,18 @@ class BlurHashFetcher( ) } - object Factory : Fetcher.Factory { + object Factory : Fetcher.Factory { override fun create( - data: Uri, + data: Blurhash, options: Options, imageLoader: ImageLoader, ): Fetcher = BlurHashFetcher(options, data) } -} -object BlurHashRequester { - fun imageRequest( - context: Context, - message: String, - ): ImageRequest { - val encodedMessage = URLEncoder.encode(message, "utf-8") - - return ImageRequest - .Builder(context) - .data("bluehash:$encodedMessage") - .fetcherFactory(BlurHashFetcher.Factory) - .build() + object BKeyer : Keyer { + override fun key( + data: Blurhash, + options: Options, + ): String = data.blurhash } } 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 406128116..0189c1490 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -27,9 +27,9 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.Event import kotlinx.collections.immutable.ImmutableList 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 aa367873a..97ed4ae06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt @@ -26,21 +26,28 @@ import android.location.Geocoder import android.location.Location import android.location.LocationListener import android.location.LocationManager +import android.os.Build import android.os.Looper import android.util.Log import android.util.LruCache +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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch class LocationFlow( @@ -90,27 +97,48 @@ class LocationState( const val MIN_DISTANCE: Float = 100.0f } - private var latestLocation: Location = Location(LocationManager.NETWORK_PROVIDER) + sealed class LocationResult { + data class Success( + val geoHash: GeoHash, + ) : LocationResult() - val locationStateFlow = - LocationFlow(context) - .get(MIN_TIME, MIN_DISTANCE) - .onEach { - latestLocation = it + object LackPermission : LocationResult() + + object Loading : LocationResult() + } + + private var hasLocationPermission = MutableStateFlow(false) + private var latestLocation: LocationResult = LocationResult.Loading + + fun setLocationPermission(newValue: Boolean) { + if (newValue != hasLocationPermission.value) { + hasLocationPermission.tryEmit(newValue) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val geohashStateFlow = + hasLocationPermission + .transformLatest { + emitAll( + LocationFlow + (context) + .get(MIN_TIME, MIN_DISTANCE) + .map { + LocationResult.Success(it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits)) as LocationResult + }.onEach { + latestLocation = it + }.catch { e -> + e.printStackTrace() + latestLocation = LocationResult.LackPermission + emit(LocationResult.LackPermission) + }, + ) }.stateIn( scope, SharingStarted.WhileSubscribed(5000), latestLocation, ) - - val geohashStateFlow = - locationStateFlow - .map { it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits).toString() } - .stateIn( - scope, - SharingStarted.WhileSubscribed(5000), - "", - ) } object CachedGeoLocations { @@ -144,8 +172,11 @@ private class ReverseGeoLocationUtil { ): String? { return try { Geocoder(context) - .getFromLocation(location.latitude, location.longitude, 1) - ?.firstOrNull() + .getFromLocation( + location.latitude, + location.longitude, + 1, + )?.firstOrNull() ?.let { address -> listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode) .joinToString(", ") @@ -157,3 +188,52 @@ private class ReverseGeoLocationUtil { } } } + +class ReverseGeoLocationFlow( + private val context: Context, +) { + @SuppressLint("MissingPermission") + fun get(location: Location): Flow = + callbackFlow { + val locationManager = Geocoder(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val locationCallback = + ( + Geocoder.GeocodeListener { addresses -> + launch { + send( + addresses.firstOrNull()?.let { + listOfNotNull(it.locality ?: it.subAdminArea, it.countryCode).joinToString(", ") + }, + ) + } + } + ) + Log.d("GeoLocation Service", "LocationState Start") + + locationManager + .getFromLocation( + location.latitude, + location.longitude, + 1, + locationCallback, + ) + } else { + launch { + send( + Geocoder(context) + .getFromLocation( + location.latitude, + location.longitude, + 1, + )?.firstOrNull() + ?.let { address -> + listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode) + .joinToString(", ") + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt index f6fcd4b1b..85de464e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index f201464f0..3056edf9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service import android.util.Log import android.util.LruCache -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.quartz.encoders.Nip11RelayInformation import com.vitorpamplona.quartz.encoders.RelayUrlFormatter import com.vitorpamplona.quartz.utils.TimeUtils 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 76ef57633..57ca3c884 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.Client import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.ammolite.relays.TypedFilter @@ -36,12 +35,14 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AppSpecificDataEvent import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.BadgeProfilesEvent +import com.vitorpamplona.quartz.events.BlossomServersEvent import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.CalendarDateSlotEvent import com.vitorpamplona.quartz.events.CalendarRSVPEvent import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent @@ -54,6 +55,8 @@ import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.events.MetadataEvent @@ -69,7 +72,6 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.flow.filter // TODO: Migrate this to a property of AccountVi object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { @@ -93,10 +95,11 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND, FileServersEvent.KIND, + BlossomServersEvent.KIND, PrivateOutboxRelayListEvent.KIND, ), authors = listOf(account.userProfile().pubkeyHex), - limit = 10, + limit = 20, ), ) @@ -115,11 +118,12 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND, FileServersEvent.KIND, + BlossomServersEvent.KIND, MuteListEvent.KIND, PeopleListEvent.KIND, ), authors = otherAuthors, - limit = otherAuthors.size * 10, + limit = otherAuthors.size * 20, ), ) } @@ -237,9 +241,12 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { GitIssueEvent.KIND, GitPatchEvent.KIND, HighlightEvent.KIND, + CommentEvent.KIND, CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND, CalendarRSVPEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, ), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), limit = 400, @@ -484,9 +491,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { super.auth(relay, challenge) if (this::account.isInitialized) { - account.createAuthEvent(relay, challenge) { - Client.sendIfExists(it, relay) - } + account.sendAuthEvent(relay, challenge) } } 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 bcdcb0998..c55222eef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -124,10 +124,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf( - "g" to - it - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), + "g" to it, ), limit = 300, since = @@ -304,13 +301,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { filter = SincePerRelayFilter( kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), + tags = mapOf("g" to hashToLoad), limit = 300, since = latestEOSEs.users[account.userProfile()] @@ -367,12 +358,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), + mapOf("g" to hashToLoad), limit = 300, since = latestEOSEs.users[account.userProfile()] @@ -426,13 +412,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { filter = SincePerRelayFilter( kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), + tags = mapOf("g" to hashToLoad), limit = 300, since = latestEOSEs.users[account.userProfile()] 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 828d0acee..4e017b6eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.HighlightEvent -import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent @@ -57,12 +57,12 @@ object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") { ChannelMessageEvent.KIND, LongTextNoteEvent.KIND, PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, WikiNoteEvent.KIND, + CommentEvent.KIND, ), limit = 200, ), 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 a8885a957..b53b6389d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -27,7 +27,9 @@ import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent @@ -37,45 +39,59 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") { private var hashtagToWatch: String? = null - fun createLoadHashtagFilter(): TypedFilter? { - val hashToLoad = hashtagToWatch ?: return null + fun createLoadHashtagFilter(): List { + val hashToLoad = hashtagToWatch ?: return emptyList() - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = - mapOf( - "t" to - listOf( - hashToLoad, - hashToLoad.lowercase(), - hashToLoad.uppercase(), - hashToLoad.capitalize(), - ), - ), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - WikiNoteEvent.KIND, - ), - limit = 200, - ), + val hashtagsToFollow = + listOf( + hashToLoad, + hashToLoad.lowercase(), + hashToLoad.uppercase(), + hashToLoad.capitalize(), + ) + + return listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + tags = mapOf("t" to hashtagsToFollow), + kinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ), + limit = 200, + ), + ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + tags = mapOf("t" to hashtagsToFollow), + kinds = + listOf( + InteractiveStorySceneEvent.KIND, + ), + limit = 200, + ), + ), ) } val loadHashtagChannel = requestNewChannel() override fun updateChannelFilters() { - loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } + loadHashtagChannel.typedFilters = createLoadHashtagFilter().ifEmpty { null } } fun loadHashtag(tag: String?) { 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 e3a9f050b..23e5cf206 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -31,9 +31,11 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent @@ -85,38 +87,57 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { job2?.cancel() } - fun createFollowAccountsFilter(): TypedFilter { + fun createFollowAccountsFilter(): List { val follows = account.liveHomeListAuthorsPerRelay.value - return TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ClassifiedsEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - PinListEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - WikiNoteEvent.KIND, - ), - authors = follows, - limit = 400, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultHomeFollowList.value) - ?.relayList, - ), + return listOf( + TypedFilter( + types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), + filter = + SinceAuthorPerRelayFilter( + kinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ClassifiedsEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + PinListEvent.KIND, + ), + authors = follows, + limit = 400, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultHomeFollowList.value) + ?.relayList, + ), + ), + TypedFilter( + types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), + filter = + SinceAuthorPerRelayFilter( + kinds = + listOf( + InteractiveStoryPrologueEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesEvent.KIND, + WikiNoteEvent.KIND, + ), + authors = follows, + limit = 400, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultHomeFollowList.value) + ?.relayList, + ), + ), ) } @@ -164,8 +185,8 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + CommentEvent.KIND, WikiNoteEvent.KIND, ), tags = @@ -202,17 +223,13 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { LongTextNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, + InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, + CommentEvent.KIND, ), tags = mapOf( - "g" to - hashToLoad - .map { listOf(it.lowercase()) } - .flatten(), + "g" to hashToLoad.toList(), ), limit = 100, since = @@ -239,11 +256,10 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { LongTextNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, WikiNoteEvent.KIND, CommunityPostApprovalEvent.KIND, + CommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ), tags = mapOf( @@ -271,12 +287,14 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { override fun updateChannelFilters() { followAccountChannel.typedFilters = - listOfNotNull( - createFollowAccountsFilter(), - createFollowMetadataAndReleaseFilter(), - createFollowCommunitiesFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter(), + ( + createFollowAccountsFilter() + + listOfNotNull( + createFollowMetadataAndReleaseFilter(), + createFollowCommunitiesFilter(), + createFollowTagsFilter(), + createFollowGeohashesFilter(), + ) ).ifEmpty { null } } } 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 71a54afae..d1091f03a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES +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 @@ -38,9 +38,12 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.MetadataEvent @@ -93,7 +96,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe listOfNotNull( ATag.parse(it, null)?.let { aTag -> TypedFilter( - types = COMMON_FEED_TYPES, + types = ALL_FEED_TYPES, filter = SincePerRelayFilter( kinds = listOf(MetadataEvent.KIND, aTag.kind), @@ -108,19 +111,19 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe // event ids listOf( TypedFilter( - types = COMMON_FEED_TYPES, + types = ALL_FEED_TYPES, filter = SincePerRelayFilter( - ids = listOfNotNull(hexToWatch), + ids = listOfNotNull(it), ), ), // authors TypedFilter( - types = COMMON_FEED_TYPES, + types = ALL_FEED_TYPES, filter = SincePerRelayFilter( kinds = listOf(MetadataEvent.KIND), - authors = listOfNotNull(hexToWatch), + authors = listOfNotNull(it), // just to be sure limit = 5, ), @@ -177,6 +180,20 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe PollNoteEvent.KIND, NNSEvent.KIND, WikiNoteEvent.KIND, + CommentEvent.KIND, + ), + search = mySearchString, + limit = 100, + ), + ), + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + SincePerRelayFilter( + kinds = + listOf( + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, ), search = mySearchString, limit = 100, 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 a9a362cb2..404fd70ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.MetadataEvent +import com.vitorpamplona.quartz.events.RelationshipStatusEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.StatusEvent @@ -71,7 +72,7 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") { types = EVENT_FINDER_TYPES, filter = SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND), + kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, RelationshipStatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND), authors = groupIds, since = minEOSEs, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index 19e0fc3f1..3e509ed3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES import com.vitorpamplona.ammolite.relays.TypedFilter @@ -28,26 +29,55 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") { private var eventToWatch: String? = null - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { - val threadToLoad = eventToWatch ?: return null + fun createLoadEventsIfNotLoadedFilter(): List { + val threadToLoad = eventToWatch ?: return emptyList() + + val branch = ThreadAssembler().findThreadFor(threadToLoad) ?: return emptyList() val eventsToLoad = - ThreadAssembler() - .findThreadFor(threadToLoad) + branch.allNotes .filter { it.event == null } .map { it.idHex } .toSet() .ifEmpty { null } - ?: return null - if (eventsToLoad.isEmpty()) return null + val address = if (branch.root is AddressableNote) branch.root.idHex else null + val event = if (branch.root !is AddressableNote) branch.root.idHex else branch.root.event?.id() - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - ids = eventsToLoad.toList(), - ), + return listOfNotNull( + eventsToLoad?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + ids = it.toList(), + ), + ) + }, + event?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + tags = + mapOf( + "e" to listOf(event), + ), + ), + ) + }, + address?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + tags = + mapOf( + "a" to listOf(address), + ), + ), + ) + }, ) } @@ -59,8 +89,7 @@ object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") { } override fun updateChannelFilters() { - loadEventsChannel.typedFilters = - listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null } + loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter() } fun loadThread(noteId: String?) { 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 daa439767..436fd4395 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -33,10 +33,12 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent @@ -44,6 +46,8 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TorrentCommentEvent import com.vitorpamplona.quartz.events.TorrentEvent +import com.vitorpamplona.quartz.events.VideoHorizontalEvent +import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.WikiNoteEvent object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { @@ -101,6 +105,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { listOf( TorrentEvent.KIND, TorrentCommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ), authors = listOf(it.pubkeyHex), limit = 20, @@ -180,7 +185,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { filter = SincePerRelayFilter( kinds = - listOf(ProfileGalleryEntryEvent.KIND), + listOf(ProfileGalleryEntryEvent.KIND, PictureEvent.KIND, VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND), authors = listOf(it.pubkeyHex), limit = 1000, ), 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 2942ec92e..62bed4179 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import kotlinx.coroutines.Dispatchers @@ -64,85 +65,136 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { job?.cancel() } - fun createContextualFilter(): TypedFilter { + fun createContextualFilter(): List { val follows = account.liveStoriesListAuthorsPerRelay.value - return TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - limit = 200, - tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), + val types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS) + + return listOf( + TypedFilter( + types = types, + filter = + SinceAuthorPerRelayFilter( + authors = follows, + kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), + limit = 200, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), + TypedFilter( + types = types, + filter = + SinceAuthorPerRelayFilter( + authors = follows, + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + limit = 200, + tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES), + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), ) } - fun createFollowTagsFilter(): TypedFilter? { + fun createFollowTagsFilter(): List { val hashToLoad = account.liveStoriesFollowLists.value ?.hashtags - ?.toList() ?: return null + ?.toList() ?: return emptyList() - if (hashToLoad.isEmpty()) return null + if (hashToLoad.isEmpty()) return emptyList() - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), + val hashtags = + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten() + + return listOf( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), + tags = mapOf("t" to hashtags), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + tags = + mapOf( + "t" to hashtags, + "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), ) } - fun createFollowGeohashesFilter(): TypedFilter? { + fun createFollowGeohashesFilter(): List { val hashToLoad = account.liveStoriesFollowLists.value ?.geotags - ?.toList() ?: return null + ?.toList() ?: return emptyList() - if (hashToLoad.isEmpty()) return null + if (hashToLoad.isEmpty()) return emptyList() - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), + val geoHashes = hashToLoad + + return listOf( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), + tags = mapOf("g" to geoHashes), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + tags = + mapOf( + "g" to geoHashes, + "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), ) } @@ -162,6 +214,6 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { createContextualFilter(), createFollowTagsFilter(), createFollowGeohashesFilter(), - ).ifEmpty { null } + ).flatten().ifEmpty { null } } } 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 9bf1b0bc2..be7a7cd07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -24,7 +24,7 @@ import android.util.Log import android.util.LruCache import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.quartz.crypto.CryptoUtils import okhttp3.EventListener import okhttp3.Protocol 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 c85bdca61..b1b639c18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.service import android.content.Context import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.collectSuccessfulOperations import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver -import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AppDefinitionEvent @@ -62,7 +62,7 @@ class ZapPaymentHandler( context: Context, showErrorIfNoLnAddress: Boolean, forceProxy: (String) -> Boolean, - onError: (String, String) -> Unit, + onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType, @@ -90,6 +90,7 @@ class ZapPaymentHandler( context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, ), + note.author, ) } return@withContext @@ -108,6 +109,7 @@ class ZapPaymentHandler( context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, ), + note.author, ) } return@withContext @@ -125,10 +127,10 @@ class ZapPaymentHandler( onProgress(0.05f) } - assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, forceProxy, onError, onProgress = { + assembleAllInvoices(splitZapRequestPairs, amountMilliSats, message, showErrorIfNoLnAddress, forceProxy, onError, onProgress = { onProgress(it * 0.7f + 0.05f) // keeps within range. - }, context) { - if (it.isEmpty()) { + }, context) { payables -> + if (payables.isEmpty()) { onProgress(0.00f) return@assembleAllInvoices } else { @@ -136,22 +138,14 @@ class ZapPaymentHandler( } if (account.hasWalletConnectSetup()) { - payViaNWC(it.values.map { it.invoice }, note, onError, onProgress = { + payViaNWC(payables, note, onError = onError, onProgress = { onProgress(it * 0.25f + 0.75f) // keeps within range. }, context) { // onProgress(1f) } } else { onPayViaIntent( - it - .map { - Payable( - info = it.key.first, - user = it.key.second.user, - amountMilliSats = it.value.zapValue, - invoice = it.value.invoice, - ) - }.toImmutableList(), + payables.toImmutableList(), ) onProgress(0f) @@ -170,7 +164,8 @@ class ZapPaymentHandler( return roundedZapValue } - class SignAllZapRequestsReturn( + class ZapRequestReady( + val inputSetup: ZapSplitSetup, val zapRequestJson: String?, val user: User? = null, ) @@ -181,7 +176,7 @@ class ZapPaymentHandler( message: String, zapType: LnZapEvent.ZapType, zapsToSend: List, - onAllDone: suspend (MutableMap) -> Unit, + onAllDone: suspend (List) -> Unit, ) { val authorRelayList = note.author @@ -195,13 +190,13 @@ class ZapPaymentHandler( )?.readRelays() }?.toSet() - collectSuccessfulSigningOperations( - operationsInput = zapsToSend, + collectSuccessfulOperations( + items = zapsToSend, runRequestFor = { next: ZapSplitSetup, onReady -> if (next.isLnAddress) { prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson -> if (zapRequestJson != null) { - onReady(SignAllZapRequestsReturn(zapRequestJson)) + onReady(ZapRequestReady(next, zapRequestJson)) } } } else { @@ -217,7 +212,7 @@ class ZapPaymentHandler( ) + (authorRelayList ?: emptySet()) prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson -> - onReady(SignAllZapRequestsReturn(zapRequestJson, user)) + onReady(ZapRequestReady(next, zapRequestJson, user)) } } }, @@ -226,32 +221,33 @@ class ZapPaymentHandler( } suspend fun assembleAllInvoices( - invoices: List>, + requests: List, totalAmountMilliSats: Long, message: String, showErrorIfNoLnAddress: Boolean, forceProxy: (String) -> Boolean, - onError: (String, String) -> Unit, + onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - onAllDone: suspend (MutableMap, AssembleInvoiceReturn>) -> Unit, + onAllDone: suspend (List) -> Unit, ) { var progressAllPayments = 0.00f - val totalWeight = invoices.sumOf { it.first.weight } + val totalWeight = requests.sumOf { it.inputSetup.weight } - collectSuccessfulSigningOperations, AssembleInvoiceReturn>( - operationsInput = invoices, - runRequestFor = { splitZapRequestPair: Pair, onReady -> + collectSuccessfulOperations( + items = requests, + runRequestFor = { splitZapRequestPair: ZapRequestReady, onReady -> assembleInvoice( - splitSetup = splitZapRequestPair.first, - nostrZapRequest = splitZapRequestPair.second.zapRequestJson, - zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight), + splitSetup = splitZapRequestPair.inputSetup, + nostrZapRequest = splitZapRequestPair.zapRequestJson, + toUser = splitZapRequestPair.user, + zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight), message = message, showErrorIfNoLnAddress = showErrorIfNoLnAddress, forceProxy = forceProxy, onError = onError, onProgressStep = { percentStepForThisPayment -> - progressAllPayments += percentStepForThisPayment / invoices.size + progressAllPayments += percentStepForThisPayment / requests.size onProgress(progressAllPayments) }, context = context, @@ -262,30 +258,35 @@ class ZapPaymentHandler( ) } + class Paid( + payable: Payable, + success: Boolean, + ) + suspend fun payViaNWC( - invoices: List, + payables: List, note: Note, - onError: (String, String) -> Unit, + onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - onAllDone: suspend (MutableMap) -> Unit, + onAllDone: suspend (List) -> Unit, ) { var progressAllPayments = 0.00f - collectSuccessfulSigningOperations( - operationsInput = invoices, - runRequestFor = { invoice: String, onReady -> + collectSuccessfulOperations( + items = payables, + runRequestFor = { payable: Payable, onReady -> account.sendZapPaymentRequestFor( - bolt11 = invoice, + bolt11 = payable.invoice, zappedNote = note, onSent = { - progressAllPayments += 0.5f / invoices.size + progressAllPayments += 0.5f / payables.size onProgress(progressAllPayments) - onReady(true) + onReady(Paid(payable, true)) }, onResponse = { response -> if (response is PayInvoiceErrorResponse) { - progressAllPayments += 0.5f / invoices.size + progressAllPayments += 0.5f / payables.size onProgress(progressAllPayments) onError( stringRes(context, R.string.error_dialog_pay_invoice_error), @@ -295,9 +296,10 @@ class ZapPaymentHandler( response.error?.message ?: response.error?.code?.toString() ?: "Error parsing error message", ), + payable.user, ) } else { - progressAllPayments += 0.5f / invoices.size + progressAllPayments += 0.5f / payables.size onProgress(progressAllPayments) } }, @@ -307,32 +309,26 @@ class ZapPaymentHandler( ) } - class AssembleInvoiceReturn( - val zapValue: Long, - val invoice: String, - ) - private fun assembleInvoice( splitSetup: ZapSplitSetup, nostrZapRequest: String?, + toUser: User?, zapValue: Long, message: String, showErrorIfNoLnAddress: Boolean = true, forceProxy: (String) -> Boolean, - onError: (String, String) -> Unit, + onError: (String, String, User?) -> Unit, onProgressStep: (percent: Float) -> Unit, context: Context, - onReady: (AssembleInvoiceReturn) -> Unit, + onReady: (Payable) -> Unit, ) { var progressThisPayment = 0.00f - var user: User? = null val lud16 = if (splitSetup.isLnAddress) { splitSetup.lnAddressOrPubKeyHex } else { - user = LocalCache.getUserIfExists(splitSetup.lnAddressOrPubKeyHex) - user?.info?.lnAddress() + toUser?.info?.lnAddress() } if (lud16 != null) { @@ -343,7 +339,9 @@ class ZapPaymentHandler( message = message, nostrRequest = nostrZapRequest, forceProxy = forceProxy, - onError = onError, + onError = { title, msg -> + onError(title, msg, toUser) + }, onProgress = { val step = it - progressThisPayment progressThisPayment = it @@ -352,7 +350,14 @@ class ZapPaymentHandler( context = context, onSuccess = { onProgressStep(1 - progressThisPayment) - onReady(AssembleInvoiceReturn(zapValue, it)) + onReady( + Payable( + info = splitSetup, + user = toUser, + amountMilliSats = zapValue, + invoice = it, + ), + ) }, ) } else { @@ -367,6 +372,7 @@ class ZapPaymentHandler( R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, user?.toBestDisplayName() ?: splitSetup.lnAddressOrPubKeyHex, ), + null, ) } } @@ -383,7 +389,6 @@ class ZapPaymentHandler( ) { if (zapType != LnZapEvent.ZapType.NONZAP) { account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest -> - println("Zap Request " + zapRequest.toJson()) onReady(zapRequest.toJson()) } } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 468735a4a..089a16e4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -24,12 +24,14 @@ import android.content.Context import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.quartz.encoders.LnInvoiceUtil import com.vitorpamplona.quartz.encoders.Lud06 import okhttp3.Request +import okhttp3.Response import java.math.BigDecimal import java.math.RoundingMode import java.net.URLEncoder @@ -95,7 +97,7 @@ class LightningAddressResolver { .the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, url, lnaddress, - it.code.toString(), + errorMessage(it, context), ), ) } @@ -154,12 +156,36 @@ class LightningAddressResolver { } else { onError( stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes(context, R.string.could_not_fetch_invoice_from, lnCallback), + stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(it, context)), ) } } } + fun errorMessage( + response: Response, + context: Context, + ): String { + val errorMessage = + runCatching { + jacksonObjectMapper().readTree(response.body.string()) + }.getOrNull()?.let { tree -> + val status = tree.get("status")?.asText() + val message = tree.get("message")?.asText() + + if (status == "error" && message != null) { + message + } else { + tree.get("error")?.get("message")?.asText() + } + } + + return errorMessage + ?: HttpStatusMessages.resourceIdFor(response.code)?.let { stringRes(context, it) } + ?: response.message.ifBlank { null } + ?: response.code.toString() + } + fun lnAddressInvoice( lnaddress: String, milliSats: Long, @@ -195,7 +221,7 @@ class LightningAddressResolver { null } - val callback = lnurlp?.get("callback")?.asText() + val callback = lnurlp?.get("callback")?.asText()?.ifBlank { null } if (callback == null) { onError( 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 8988d1664..fc4db56e5 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 @@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDM import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.encoders.toNpub +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event @@ -58,7 +60,6 @@ class EventNotificationConsumer( suspend fun consume(event: GiftWrapEvent) { Log.d(TAG, "New Notification Arrived") if (!LocalCache.justVerify(event)) return - if (!notificationManager().areNotificationsEnabled()) return // PushNotification Wraps don't include a receiver. // Test with all logged in accounts @@ -94,22 +95,71 @@ class EventNotificationConsumer( } pushWrappedEvent.unwrapThrowing(signer) { notificationEvent -> - val consumed = LocalCache.hasConsumed(notificationEvent) - val verified = LocalCache.justVerify(notificationEvent) - Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified") - if (!consumed && verified) { - Log.d(TAG, "New Notification was verified") - unwrapAndConsume(notificationEvent, signer) { innerEvent -> - Log.d(TAG, "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}") - if (innerEvent is PrivateDmEvent) { - Log.d(TAG, "New Nip-04 DM to Notify") - notify(innerEvent, signer, account) - } else if (innerEvent is LnZapEvent) { - Log.d(TAG, "New Zap to Notify") - notify(innerEvent, signer, account) - } else if (innerEvent is ChatMessageEvent) { - Log.d(TAG, "New ChatMessage to Notify") - notify(innerEvent, signer, account) + consumeNotificationEvent(notificationEvent, signer, account) + } + } + + fun consumeNotificationEvent( + notificationEvent: Event, + signer: NostrSigner, + account: AccountSettings, + ) { + val consumed = LocalCache.hasConsumed(notificationEvent) + val verified = LocalCache.justVerify(notificationEvent) + Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified") + if (!consumed && verified) { + Log.d(TAG, "New Notification was verified") + unwrapAndConsume(notificationEvent, signer) { innerEvent -> + if (!notificationManager().areNotificationsEnabled()) return@unwrapAndConsume + + Log.d(TAG, "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}") + if (innerEvent is PrivateDmEvent) { + Log.d(TAG, "New Nip-04 DM to Notify") + notify(innerEvent, signer, account) + } else if (innerEvent is LnZapEvent) { + Log.d(TAG, "New Zap to Notify") + notify(innerEvent, signer, account) + } else if (innerEvent is ChatMessageEvent) { + Log.d(TAG, "New ChatMessage to Notify") + notify(innerEvent, signer, account) + } else if (innerEvent is ChatMessageEncryptedFileHeaderEvent) { + Log.d(TAG, "New ChatMessage File to Notify") + notify(innerEvent, signer, account) + } + } + } + } + + suspend fun findAccountAndConsume(event: Event) { + Log.d(TAG, "New Notification Arrived") + if (!LocalCache.justVerify(event)) return + + val users = event.taggedUsers().map { LocalCache.getOrCreateUser(it) } + val npubs = users.map { it.pubkeyNpub() }.toSet() + + // PushNotification Wraps don't include a receiver. + // Test with all logged in accounts + var matchAccount = false + LocalPreferences.allSavedAccounts().forEach { + if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) { + LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc -> + Log.d(TAG, "New Notification Testing if for ${it.npub}") + try { + // TODO: Modify the external launcher to launch as different users. + // Right now it only registers if Amber has already approved this signature + val signer = acc.createSigner() + if (signer is NostrSignerExternal) { + signer.launcher.registerLauncher( + launcher = { }, + contentResolver = Amethyst.instance::contentResolverFn, + ) + } + + consumeNotificationEvent(event, signer, acc) + matchAccount = true + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}") } } } @@ -148,6 +198,51 @@ class EventNotificationConsumer( } } + private fun notify( + event: ChatMessageEncryptedFileHeaderEvent, + signer: NostrSigner, + acc: AccountSettings, + ) { + if ( + // old event being re-broadcasted + event.createdAt > TimeUtils.fifteenMinutesAgo() && + // don't display if it comes from me. + event.pubKey != signer.pubKey + ) { // from the user + Log.d(TAG, "Notifying") + val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return + val chatNote = LocalCache.getNoteIfExists(event.id) ?: return + val chatRoom = event.chatroomKey(signer.pubKey) + + val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return + + val isKnownRoom = + ( + myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || + myUser.hasSentMessagesTo(chatRoom) + ) + + if (isKnownRoom) { + val content = chatNote.event?.content() ?: "" + val user = chatNote.author?.toBestDisplayName() ?: "" + val userPicture = chatNote.author?.profilePicture() + val noteUri = chatNote.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub() + + // TODO: Show Image on notification + notificationManager() + .sendDMNotification( + event.id, + content, + user, + event.createdAt, + userPicture, + noteUri, + applicationContext, + ) + } + } + } + private fun notify( event: ChatMessageEvent, signer: NostrSigner, @@ -176,7 +271,7 @@ class EventNotificationConsumer( val content = chatNote.event?.content() ?: "" val user = chatNote.author?.toBestDisplayName() ?: "" val userPicture = chatNote.author?.profilePicture() - val noteUri = chatNote.toNEvent() + val noteUri = chatNote.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub() notificationManager() .sendDMNotification( event.id, @@ -216,7 +311,7 @@ class EventNotificationConsumer( decryptContent(note, signer) { content -> val user = note.author?.toBestDisplayName() ?: "" val userPicture = note.author?.profilePicture() - val noteUri = note.toNEvent() + val noteUri = note.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub() notificationManager() .sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext) } @@ -322,7 +417,7 @@ class EventNotificationConsumer( ) } val userPicture = senderInfo.first.profilePicture() - val noteUri = "nostr:Notifications" + val noteUri = "notifications?account=" + acc.keyPair.pubKey.toNpub() Log.d(TAG, "Notify ${event.id} $content $title $noteUri") @@ -353,9 +448,9 @@ class EventNotificationConsumer( ) val userPicture = senderInfo.first.profilePicture() - val noteUri = "nostr:Notifications" + val noteUri = "notifications?account=" + acc.keyPair.pubKey.toNpub() - Log.d(TAG, "Notify ${event.id} $content $title $noteUri") + Log.d(TAG, "Notify ${event.id} $title $noteUri") notificationManager() .sendZapNotification( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index bfde8a1da..016ca7817 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -221,9 +221,6 @@ object NotificationUtils { .setContentTitle(messageTitle) .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) .setLargeIcon(picture?.bitmap) - // .setGroup(messageTitle) - // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we - // activate this .setContentIntent(contentPendingIntent) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) @@ -239,9 +236,6 @@ object NotificationUtils { .setContentTitle(messageTitle) .setContentText(messageBody) .setLargeIcon(picture?.bitmap) - // .setGroup(messageTitle) - // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we - // activate this .setContentIntent(contentPendingIntent) .setPublicVersion(builderPublic.build()) .setPriority(NotificationCompat.PRIORITY_HIGH) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt new file mode 100644 index 000000000..07c54c582 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.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.amethyst.service.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.vitorpamplona.quartz.events.Event +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class PokeyReceiver : BroadcastReceiver() { + companion object { + val POKEY_ACTION = "com.shared.NOSTR" + val TAG = "PokeyReceiver" + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (intent.action == POKEY_ACTION) { // it's best practice to verify intent action before performing any operation + val eventStr = intent.getStringExtra("EVENT") + Log.d(TAG, "New Pokey Notification Arrived $eventStr") + + if (eventStr == null) return + + scope.launch(Dispatchers.IO) { + try { + EventNotificationConsumer(context.applicationContext).findAccountAndConsume(Event.fromJson(eventStr)) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse Pokey Event", e) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 1084cc338..567fc7452 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -25,18 +25,15 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.launchAndWaitAll import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.signers.NostrSignerExternal import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -63,38 +60,26 @@ class RegisterAccounts( return } - coroutineScope { - val jobs = - remainingTos.map { accountRelayPair -> - async { - val result = - withTimeoutOrNull(10000) { - suspendCancellableCoroutine { continuation -> - val signer = accountRelayPair.first.createSigner() - // TODO: Modify the external launcher to launch as different users. - // Right now it only registers if Amber has already approved this signature - if (signer is NostrSignerExternal) { - signer.launcher.registerLauncher( - launcher = { }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } + launchAndWaitAll(remainingTos) { accountRelayPair -> + val result = + tryAndWait { continuation -> + val signer = accountRelayPair.first.createSigner() + // TODO: Modify the external launcher to launch as different users. + // Right now it only registers if Amber has already approved this signature + if (signer is NostrSignerExternal) { + signer.launcher.registerLauncher( + launcher = { }, + contentResolver = Amethyst.instance::contentResolverFn, + ) + } - RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result -> - continuation.resume(result) - } - } - } - - if (result != null) { - output.add(result) - } + RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result -> + continuation.resume(result) } } - // runs in parallel to avoid overcrowding Amber. - withTimeoutOrNull(15000) { - jobs.joinAll() + if (result != null) { + output.add(result) } } @@ -181,8 +166,6 @@ class RegisterAccounts( if (notificationToken.isNotEmpty()) { withContext(Dispatchers.IO) { signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } - - PushNotificationUtils.hasInit = true } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.kt new file mode 100644 index 000000000..e53ae2ee1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.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.service.okhttp + +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +class DefaultContentTypeInterceptor( + private val userAgentHeader: String, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + val requestWithUserAgent: Request = + originalRequest + .newBuilder() + .header("User-Agent", userAgentHeader) + .build() + return chain.proceed(requestWithUserAgent) + } +} 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 new file mode 100644 index 000000000..c42df6d5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.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.amethyst.service.okhttp + +import android.util.Log +import com.vitorpamplona.quartz.crypto.nip17.AESGCM +import com.vitorpamplona.quartz.crypto.nip17.NostrCipher +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody + +class EncryptedBlobInterceptor( + val cache: EncryptionKeyCache, +) : Interceptor { + fun Response.decrypt(cipher: NostrCipher): 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 + } + + private fun Response.decryptOrNullWithErrorCorrection(cipher: NostrCipher): Response? { + return decryptOrNull(cipher) ?: return if (cipher is AESGCM) { + decryptOrNull(cipher.copyUsingUTF8Nonce()) + } else { + null + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + val cipher = cache.get(request.url.toString()) ?: return response + + if (response.isSuccessful) { + return response.decryptOrNullWithErrorCorrection(cipher) ?: response + } else { + // Log redirections to be able to use the cipher. + response.header("Location")?.let { + cache.add(it, cipher) + } + } + 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 new file mode 100644 index 000000000..7c9006525 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.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.service.okhttp + +import android.util.LruCache +import com.vitorpamplona.quartz.crypto.nip17.NostrCipher + +/** + * Neigther ExoPlayer, nor Coil support passing key and nonce to the Interceptor via + * Request.tag, which would be the right way to do this. + * + * This class serves as a key cache to decrypt the body of HTTP calls that need it. + */ +class EncryptionKeyCache { + val cache = LruCache(100) + + fun add( + url: String?, + cipher: NostrCipher, + ) { + if (cache.get(url) == null) { + cache.put(url, cipher) + } + } + + fun get(url: String): NostrCipher? = cache.get(url) +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/service/HttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt similarity index 59% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/service/HttpClientManager.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt index 3a48c4fcf..b3dd23c4b 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/service/HttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt @@ -18,43 +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.ammolite.service +package com.vitorpamplona.amethyst.service.okhttp import android.util.Log -import com.vitorpamplona.ammolite.service.HttpClientManager.setDefaultProxy -import okhttp3.Interceptor +import com.vitorpamplona.quartz.crypto.nip17.NostrCipher import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import java.io.IOException import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration -class LoggingInterceptor : Interceptor { - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val request: Request = chain.request() - val t1 = System.nanoTime() - val port = - ( - chain - .connection() - ?.route() - ?.proxy - ?.address() as? InetSocketAddress - )?.port - val response: Response = chain.proceed(request) - val t2 = System.nanoTime() - - Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms") - - return response - } -} - object HttpClientManager { - val rootClient = + private val rootClient = OkHttpClient .Builder() .followRedirects(true) @@ -71,35 +45,37 @@ object HttpClientManager { private var currentProxy: Proxy? = null + private val cache = EncryptionKeyCache() + fun setDefaultProxy(proxy: Proxy?) { if (currentProxy != proxy) { Log.d("HttpClient", "Changing proxy to: ${proxy != null}") - this.currentProxy = proxy + currentProxy = proxy // recreates singleton - this.defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout) + defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout) } } - fun getCurrentProxy(): Proxy? = this.currentProxy + fun getCurrentProxy(): Proxy? = currentProxy fun setDefaultTimeout(timeout: Duration) { Log.d("HttpClient", "Changing timeout to: $timeout") - if (this.defaultTimeout.seconds != timeout.seconds) { - this.defaultTimeout = timeout + if (defaultTimeout.seconds != timeout.seconds) { + defaultTimeout = timeout // recreates singleton - this.defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout) - this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout) + defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout) + defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout) } } fun setDefaultUserAgent(userAgentHeader: String) { Log.d("HttpClient", "Changing userAgent") if (userAgent != userAgentHeader) { - this.userAgent = userAgentHeader - this.defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout) - this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout) + userAgent = userAgentHeader + defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout) + defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout) } } @@ -117,24 +93,10 @@ object HttpClientManager { .writeTimeout(duration) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) .addNetworkInterceptor(LoggingInterceptor()) + .addNetworkInterceptor(EncryptedBlobInterceptor(cache)) .build() } - class DefaultContentTypeInterceptor( - private val userAgentHeader: String, - ) : Interceptor { - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest: Request = chain.request() - val requestWithUserAgent: Request = - originalRequest - .newBuilder() - .header("User-Agent", userAgentHeader) - .build() - return chain.proceed(requestWithUserAgent) - } - } - fun getCurrentProxyPort(useProxy: Boolean): Int? = if (useProxy) { (currentProxy?.address() as? InetSocketAddress)?.port @@ -144,13 +106,13 @@ object HttpClientManager { fun getHttpClient(useProxy: Boolean): OkHttpClient = if (useProxy) { - if (this.defaultHttpClient == null) { - this.defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout) + if (defaultHttpClient == null) { + defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout) } defaultHttpClient!! } else { - if (this.defaultHttpClientWithoutProxy == null) { - this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout) + if (defaultHttpClientWithoutProxy == null) { + defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout) } defaultHttpClientWithoutProxy!! } @@ -158,4 +120,9 @@ object HttpClientManager { fun setDefaultProxyOnPort(port: Int) { setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) } + + fun addCipherToCache( + url: String, + cipher: NostrCipher, + ) = cache.add(url, cipher) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt new file mode 100644 index 000000000..4f55de1bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.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.service.okhttp + +import android.util.Log +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import java.net.InetSocketAddress + +class LoggingInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + val t1 = System.nanoTime() + val port = + ( + chain + .connection() + ?.route() + ?.proxy + ?.address() as? InetSocketAddress + )?.port + val response: Response = chain.proceed(request) + val t2 = System.nanoTime() + + Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms") + + return response + } +} 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 new file mode 100644 index 000000000..e2651ef63 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.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.amethyst.service.okhttp + +import com.vitorpamplona.ammolite.sockets.WebSocket +import com.vitorpamplona.ammolite.sockets.WebSocketListener +import com.vitorpamplona.ammolite.sockets.WebsocketBuilder +import okhttp3.Request +import okhttp3.Response + +class OkHttpWebSocket( + val url: String, + val forceProxy: Boolean, + val out: WebSocketListener, +) : WebSocket { + private val listener = OkHttpWebsocketListener() + private var socket: okhttp3.WebSocket? = null + + fun buildRequest() = Request.Builder().url(url.trim()).build() + + override fun connect() { + socket = HttpClientManager.getHttpClient(forceProxy).newWebSocket(buildRequest(), listener) + } + + inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() { + override fun onOpen( + webSocket: okhttp3.WebSocket, + response: Response, + ) = out.onOpen( + response.receivedResponseAtMillis - response.sentRequestAtMillis, + response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false, + ) + + override fun onMessage( + webSocket: okhttp3.WebSocket, + text: String, + ) = out.onMessage(text) + + override fun onClosing( + webSocket: okhttp3.WebSocket, + code: Int, + reason: String, + ) = out.onClosing(code, reason) + + override fun onClosed( + webSocket: okhttp3.WebSocket, + code: Int, + reason: String, + ) = out.onClosed(code, reason) + + override fun onFailure( + webSocket: okhttp3.WebSocket, + t: Throwable, + response: Response?, + ) = out.onFailure(t, response?.message) + } + + class Builder : WebsocketBuilder { + override fun build( + url: String, + forceProxy: Boolean, + out: WebSocketListener, + ) = OkHttpWebSocket(url, forceProxy, out) + } + + override fun cancel() { + socket?.cancel() + } + + override fun send(msg: String): Boolean = socket?.send(msg) ?: false +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt index 43faba016..18f78207a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt @@ -24,7 +24,7 @@ import android.util.Log import android.util.LruCache import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.quartz.ots.BitcoinExplorer import com.vitorpamplona.quartz.ots.BlockHeader import com.vitorpamplona.quartz.ots.exceptions.UrlException diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt index 076977ac5..561d8fa2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.ots import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.ots.ICalendar import com.vitorpamplona.quartz.ots.StreamDeserializationContext diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt index 1630d5251..e04092040 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.ots import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit import com.vitorpamplona.quartz.ots.StreamDeserializationContext import com.vitorpamplona.quartz.ots.Timestamp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt index c0c1f0b97..7619f61aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt @@ -91,7 +91,7 @@ class MultiPlayerPlaybackManager( val player = ExoPlayer.Builder(context).run { - dataSourceFactory?.let { setMediaSourceFactory(it) } + setMediaSourceFactory(dataSourceFactory) build() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt index f36a5a2b7..6888b24ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt @@ -26,7 +26,7 @@ import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager class PlaybackService : MediaSessionService() { private var videoViewedPositionCache = VideoViewedPositionCache() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt index 99a11aa56..4f9c98c47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.previews import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext 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 new file mode 100644 index 000000000..188993569 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.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.service.uploads + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.crypto.nip17.NostrCipher +import com.vitorpamplona.quartz.encoders.toHexKey +import java.io.File + +class EncryptFilesResult( + val uri: Uri, + val contentType: String?, + val originalHash: String, + val encryptedHash: String, + val size: Long?, +) + +class EncryptFiles { + fun encryptFile( + context: Context, + inputFile: Uri, + cipher: NostrCipher, + ): EncryptFilesResult { + val resolver = context.contentResolver + + val encryptedFile = File.createTempFile("EncryptFiles", ".encrypted", context.getCacheDir()) + + resolver.openInputStream(inputFile)!!.use { inputStream -> + val bytes = inputStream.readBytes() + val originalHash = CryptoUtils.sha256(bytes).toHexKey() + val encrypted = cipher.encrypt(bytes) + val encryptedHash = CryptoUtils.sha256(encrypted).toHexKey() + + encryptedFile.outputStream().use { outputStream -> + outputStream.write(encrypted) + } + + return EncryptFilesResult( + encryptedFile.toUri(), + "application/octet-stream", + originalHash, + encryptedHash, + encrypted.size.toLong(), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index 1e3d6d5be..2564574f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.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.service +package com.vitorpamplona.amethyst.service.uploads import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -28,52 +28,51 @@ import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever.BitmapParams import android.os.Build import android.util.Log -import com.vitorpamplona.amethyst.ui.actions.ImageDownloader +import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash +import com.vitorpamplona.amethyst.service.Blurhash import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.toHexKey -import io.trbl.blurhash.BlurHash import kotlinx.coroutines.CancellationException import java.io.IOException -import kotlin.math.roundToInt class FileHeader( val mimeType: String?, val hash: String, val size: Int, - val dim: String?, - val blurHash: String?, + val dim: Dimension?, + val blurHash: Blurhash?, ) { + class UnableToDownload( + val fileUrl: String, + ) : Exception() + companion object { suspend fun prepare( fileUrl: String, mimeType: String?, - dimPrecomputed: String?, + dimPrecomputed: Dimension?, forceProxy: Boolean, - onReady: (FileHeader) -> Unit, - onError: (String) -> Unit, - ) { + ): Result = try { val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy) if (imageData != null) { - prepare(imageData, mimeType, dimPrecomputed, onReady, onError) + prepare(imageData, mimeType, dimPrecomputed) } else { - onError("Unable to download image from $fileUrl") + Result.failure(UnableToDownload(fileUrl)) } } catch (e: Exception) { if (e is CancellationException) throw e Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") - onError(e.message ?: e.javaClass.simpleName) + Result.failure(e) } - } fun prepare( data: ByteArray, mimeType: String?, - dimPrecomputed: String?, - onReady: (FileHeader) -> Unit, - onError: (String) -> Unit, - ) { + dimPrecomputed: Dimension?, + ): Result = try { val hash = CryptoUtils.sha256(data).toHexKey() val size = data.size @@ -83,90 +82,15 @@ class FileHeader( val opt = BitmapFactory.Options() opt.inPreferredConfig = Bitmap.Config.ARGB_8888 val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) - - val intArray = IntArray(mBitmap.width * mBitmap.height) - mBitmap.getPixels( - intArray, - 0, - mBitmap.width, - 0, - 0, - mBitmap.width, - mBitmap.height, - ) - - val dim = "${mBitmap.width}x${mBitmap.height}" - - val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() - - if (aspectRatio > 1) { - Pair( - BlurHash.encode( - intArray, - mBitmap.width, - mBitmap.height, - 9, - (9 * (1 / aspectRatio)).roundToInt(), - ), - dim, - ) - } else if (aspectRatio < 1) { - Pair( - BlurHash.encode( - intArray, - mBitmap.width, - mBitmap.height, - (9 * aspectRatio).roundToInt(), - 9, - ), - dim, - ) - } else { - Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim) - } + Pair(Blurhash(mBitmap.toBlurhash()), Dimension(mBitmap.width, mBitmap.height)) } else if (mimeType?.startsWith("video/") == true) { val mediaMetadataRetriever = MediaMetadataRetriever() mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed + val blurhash = mediaMetadataRetriever.getThumbnail()?.toBlurhash()?.let { Blurhash(it) } - val blurhash = - mediaMetadataRetriever.getThumbnail()?.let { thumbnail -> - val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat() - - val intArray = IntArray(thumbnail.width * thumbnail.height) - thumbnail.getPixels( - intArray, - 0, - thumbnail.width, - 0, - 0, - thumbnail.width, - thumbnail.height, - ) - - if (aspectRatio > 1) { - BlurHash.encode( - intArray, - thumbnail.width, - thumbnail.height, - 9, - (9 * (1 / aspectRatio)).roundToInt(), - ) - } else if (aspectRatio < 1) { - BlurHash.encode( - intArray, - thumbnail.width, - thumbnail.height, - (9 * aspectRatio).roundToInt(), - 9, - ) - } else { - BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4) - } - } - - if (newDim != "0x0") { + if (newDim?.hasSize() == true) { Pair(blurhash, newDim) } else { Pair(blurhash, null) @@ -175,13 +99,12 @@ class FileHeader( Pair(null, null) } - onReady(FileHeader(mimeType, hash, size, dim, blurHash)) + Result.success(FileHeader(mimeType, hash, size, dim, blurHash)) } catch (e: Exception) { if (e is CancellationException) throw e Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") - onError(e.message ?: e.javaClass.simpleName) + Result.failure(e) } - } } } @@ -212,11 +135,15 @@ fun MediaMetadataRetriever.getThumbnail(): Bitmap? { } } -fun MediaMetadataRetriever.prepareDimFromVideo(): String? { +fun MediaMetadataRetriever.prepareDimFromVideo(): Dimension? { val width = prepareVideoWidth() ?: return null val height = prepareVideoHeight() ?: return null - return "${width}x$height" + return if (width > 0 && height > 0) { + Dimension(width, height) + } else { + null + } } fun MediaMetadataRetriever.prepareVideoWidth(): Int? { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt new file mode 100644 index 000000000..7645ba560 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads + +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL + +class ImageDownloader { + suspend fun waitAndGetImage( + imageUrl: String, + forceProxy: Boolean, + ): ByteArray? = + withContext(Dispatchers.IO) { + var imageData: ByteArray? = null + var tentatives = 0 + + // Servers are usually not ready.. so tries to download it for 15 times/seconds. + while (imageData == null && tentatives < 15) { + imageData = + try { + tryGetTheImage(imageUrl, forceProxy) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + if (imageData == null) { + tentatives++ + delay(1000) + } + } + + return@withContext imageData + } + + private suspend fun tryGetTheImage( + imageUrl: String, + forceProxy: Boolean, + ): ByteArray? = + withContext(Dispatchers.IO) { + // TODO: Migrate to OkHttp + HttpURLConnection.setFollowRedirects(true) + var url = URL(imageUrl) + var huc = + if (forceProxy) { + url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection + } else { + url.openConnection() as HttpURLConnection + } + huc.instanceFollowRedirects = true + var responseCode = huc.responseCode + + if (responseCode in 300..400) { + val newUrl: String = huc.getHeaderField("Location") + + // open the new connnection again + url = URL(newUrl) + huc = + if (forceProxy) { + url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection + } else { + url.openConnection() as HttpURLConnection + } + responseCode = huc.responseCode + } + + return@withContext if (responseCode in 200..300) { + huc.inputStream.use { it.readBytes() } + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt new file mode 100644 index 000000000..4dd3b8301 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -0,0 +1,221 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.core.net.toUri +import com.abedelazizshe.lightcompressorlibrary.CompressionListener +import com.abedelazizshe.lightcompressorlibrary.VideoCompressor +import com.abedelazizshe.lightcompressorlibrary.VideoQuality +import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration +import com.abedelazizshe.lightcompressorlibrary.config.Configuration +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils +import id.zelory.compressor.Compressor +import id.zelory.compressor.constraint.default +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File +import java.util.UUID +import kotlin.coroutines.resume + +class MediaCompressorResult( + val uri: Uri, + val contentType: String?, + val size: Long?, +) + +class MediaCompressor { + // ALL ERRORS ARE IGNORED. The original file is returned. + suspend fun compress( + uri: Uri, + contentType: String?, + mediaQuality: CompressorQuality, + applicationContext: Context, + ): MediaCompressorResult { + // Skip compression if user selected uncompressed + if (mediaQuality == CompressorQuality.UNCOMPRESSED) { + Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.") + return MediaCompressorResult(uri, contentType, null) + } + + checkNotInMainThread() + + // branch into compression based on content type + return when { + contentType?.startsWith("video", ignoreCase = true) == true -> compressVideo(uri, contentType, applicationContext, mediaQuality) + contentType?.startsWith("image", ignoreCase = true) == true && + !contentType.contains("gif") && + !contentType.contains("svg") -> + compressImage(uri, contentType, applicationContext, mediaQuality) + else -> MediaCompressorResult(uri, contentType, null) + } + } + + private suspend fun compressVideo( + uri: Uri, + contentType: String?, + applicationContext: Context, + mediaQuality: CompressorQuality, + ): MediaCompressorResult { + val videoQuality = + when (mediaQuality) { + CompressorQuality.VERY_LOW -> VideoQuality.VERY_LOW + CompressorQuality.LOW -> VideoQuality.LOW + CompressorQuality.MEDIUM -> VideoQuality.MEDIUM + CompressorQuality.HIGH -> VideoQuality.HIGH + CompressorQuality.VERY_HIGH -> VideoQuality.VERY_HIGH + else -> VideoQuality.MEDIUM + } + + Log.d("MediaCompressor", "Using video compression $mediaQuality") + + val result = + withTimeoutOrNull(30000) { + suspendCancellableCoroutine { continuation -> + VideoCompressor.start( + // => This is required + context = applicationContext, + // => Source can be provided as content uris + uris = listOf(uri), + isStreamable = false, + // THIS STORAGE + // sharedStorageConfiguration = SharedStorageConfiguration( + // saveAt = SaveLocation.movies, // => default is movies + // videoName = "compressed_video" // => required name + // ), + // OR AND NOT BOTH + appSpecificStorageConfiguration = AppSpecificStorageConfiguration(), + configureWith = + Configuration( + quality = videoQuality, + // => required name + videoNames = listOf(UUID.randomUUID().toString()), + ), + listener = + object : CompressionListener { + override fun onProgress( + index: Int, + percent: Float, + ) {} + + override fun onStart(index: Int) {} + + override fun onSuccess( + index: Int, + size: Long, + path: String?, + ) { + if (path != null) { + Log.d("MediaCompressor", "Video compression success. Compressed size [$size]") + continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size)) + } else { + Log.d("MediaCompressor", "Video compression successful, but returned null path") + continuation.resume(null) + } + } + + override fun onFailure( + index: Int, + failureMessage: String, + ) { + Log.d("MediaCompressor", "Video compression failed: $failureMessage") + // keeps going with original video + continuation.resume(null) + } + + override fun onCancelled(index: Int) { + continuation.resume(null) + } + }, + ) + } + } + + return result ?: MediaCompressorResult(uri, contentType, null) + } + + private suspend fun compressImage( + uri: Uri, + contentType: String?, + context: Context, + mediaQuality: CompressorQuality, + ): MediaCompressorResult { + val imageQuality = + when (mediaQuality) { + CompressorQuality.VERY_LOW -> 40 + CompressorQuality.LOW -> 50 + CompressorQuality.MEDIUM -> 60 + CompressorQuality.HIGH -> 80 + CompressorQuality.VERY_HIGH -> 90 + else -> 60 + } + + return try { + Log.d("MediaCompressor", "Using image compression $mediaQuality") + val tempFile = MediaCompressorFileUtils.from(uri, context) + val compressedImageFile = + Compressor.compress(context, tempFile) { + default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality) + } + Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]") + MediaCompressorResult(compressedImageFile.toUri(), contentType, compressedImageFile.length()) + } catch (e: Exception) { + Log.d("MediaCompressor", "Image compression failed: ${e.message}") + if (e is CancellationException) throw e + e.printStackTrace() + MediaCompressorResult(uri, contentType, null) + } + } + + companion object { + fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality = + when (mediaQualityFloat) { + 0 -> CompressorQuality.LOW + 1 -> CompressorQuality.MEDIUM + 2 -> CompressorQuality.HIGH + 3 -> CompressorQuality.UNCOMPRESSED + else -> CompressorQuality.MEDIUM + } + + fun compressorQualityToInt(compressorQuality: CompressorQuality): Int = + when (compressorQuality) { + CompressorQuality.LOW -> 0 + CompressorQuality.MEDIUM -> 1 + CompressorQuality.HIGH -> 2 + CompressorQuality.UNCOMPRESSED -> 3 + else -> 1 + } + } +} + +enum class CompressorQuality { + VERY_LOW, + LOW, + MEDIUM, + HIGH, + VERY_HIGH, + UNCOMPRESSED, +} 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 new file mode 100644 index 000000000..b0d4eccf6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.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.service.uploads + +import com.vitorpamplona.quartz.encoders.Dimension +import com.vitorpamplona.quartz.encoders.HexKey + +data class MediaUploadResult( + // A publicly accessible URL to the BUD-01 GET / endpoint (optionally with a file extension) + val url: String?, + // The sha256 hash of the blob + val sha256: HexKey? = null, + // The size of the blob in bytes + val size: Long? = null, + // (optional) The MIME type of the blob + val type: String? = null, + // upload time + val uploaded: Long? = null, + // dimensions + val dimension: Dimension? = null, + // magnet link + val magnet: String? = null, + // info hash + val infohash: String? = null, + // ipfs link + val ipfs: String? = null, +) 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 new file mode 100644 index 000000000..f460e7f4f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.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.service.uploads + +import android.content.Context +import androidx.compose.runtime.Stable +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.crypto.nip17.NostrCipher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch + +class MultiOrchestrator( + uris: List, +) { + private var list: List = uris.map { SelectedMediaProcessing(it) } + + @Stable + class Result( + val allGood: Boolean, + val successful: List, + val errors: List, + ) + + fun first() = list.first() + + suspend fun upload( + scope: CoroutineScope, + alt: String?, + sensitiveContent: Boolean, + mediaQuality: CompressorQuality, + server: ServerName, + account: Account, + context: Context, + ): Result { + val jobs = + list.map { item -> + scope.launch(Dispatchers.IO) { + item.orchestrator.upload( + item.media.uri, + item.media.mimeType, + alt, + sensitiveContent, + mediaQuality, + server, + account, + context, + ) + } + } + + jobs.joinAll() + + return computeFinalResults() + } + + suspend fun uploadEncrypted( + scope: CoroutineScope, + alt: String?, + sensitiveContent: Boolean, + mediaQuality: CompressorQuality, + cipher: NostrCipher, + server: ServerName, + account: Account, + context: Context, + ): Result { + val jobs = + list.map { item -> + scope.launch(Dispatchers.IO) { + item.orchestrator.uploadEncrypted( + item.media.uri, + item.media.mimeType, + alt, + sensitiveContent, + mediaQuality, + cipher, + server, + account, + context, + ) + } + } + + jobs.joinAll() + + return computeFinalResults() + } + + private fun computeFinalResults(): Result { + val resultsByState = + list.map { + it.orchestrator.progressState.value + } + + val finished = resultsByState.filterIsInstance() + val errors = resultsByState.filterIsInstance() + + return Result(finished.size == list.size, finished, errors) + } + + fun remove(selected: SelectedMediaProcessing) { + list = list.filter { it != selected } + } + + fun size() = list.size + + fun get(index: Int) = list.get(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 new file mode 100644 index 000000000..b8766d932 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -0,0 +1,330 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads + +import android.content.Context +import android.net.Uri +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState +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.crypto.nip17.NostrCipher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlin.coroutines.cancellation.CancellationException + +sealed class UploadingState { + data object Ready : UploadingState() + + data object Compressing : UploadingState() + + data object Uploading : UploadingState() + + data object ServerProcessing : UploadingState() + + data object Downloading : UploadingState() + + data object Hashing : UploadingState() + + sealed class UploadingFinalState : UploadingState() + + class Finished( + val result: UploadOrchestrator.OrchestratorResult, + ) : UploadingFinalState() + + class Error( + val errorResource: Int, + val params: Array, + ) : UploadingFinalState() +} + +class UploadOrchestrator { + val progress = MutableStateFlow(0.0) + val progressState = MutableStateFlow(UploadingState.Ready) + + val isUploading = + progressState.map { + progressState.value !is UploadingState.Ready && progressState.value !is UploadingState.Error && progressState.value !is UploadingState.Finished + } + + fun error( + resId: Int, + vararg params: String, + ) = UploadingState.Error(resId, params).also { updateState(0.0, it) } + + fun finish(result: OrchestratorResult) = + UploadingState + .Finished(result) + .also { updateState(1.0, it) } + + fun updateState( + newProgress: Double, + newState: UploadingState, + ) { + progress.value = newProgress + progressState.value = newState + } + + private fun uploadNIP95( + fileUri: Uri, + contentType: String?, + originalContentType: String?, + originalHash: String?, + context: Context, + ): UploadingFinalState { + updateState(0.4, UploadingState.Uploading) + + val bytes = + context.contentResolver.openInputStream(fileUri)?.use { + it.readBytes() + } + + if (bytes != null) { + if (bytes.size > 80000) { + return error(R.string.media_too_big_for_nip95) + } + + updateState(0.8, UploadingState.Hashing) + + val result = + FileHeader.prepare( + bytes, + contentType, + null, + ) + + result.fold( + onSuccess = { + return finish(OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash)) + }, + onFailure = { + return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName) + }, + ) + } else { + return error(R.string.could_not_open_the_compressed_file) + } + } + + private suspend fun uploadNIP96( + fileUri: Uri, + contentType: String?, + size: Long?, + alt: String?, + sensitiveContent: Boolean, + serverBaseUrl: String, + contentTypeForResult: String?, + originalHash: String?, + account: Account, + context: Context, + ): UploadingFinalState { + updateState(0.2, UploadingState.Uploading) + return try { + val result = + Nip96Uploader().uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + serverBaseUrl = serverBaseUrl, + forceProxy = account::shouldUseTorForNIP96, + onProgress = { percent: Float -> + updateState(0.2 + (0.2 * percent), UploadingState.Uploading) + }, + httpAuth = account::createHTTPAuthorization, + context = context, + ) + + verifyHeader( + uploadResult = result, + localContentType = contentType, + originalContentType = contentTypeForResult, + originalHash = originalHash, + forceProxy = account::shouldUseTorForNIP96, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName) + } + } + + private suspend fun uploadBlossom( + fileUri: Uri, + contentType: String?, + size: Long?, + alt: String?, + sensitiveContent: Boolean, + serverBaseUrl: String, + contentTypeForResult: String?, + originalHash: String?, + account: Account, + context: Context, + ): UploadingFinalState { + updateState(0.2, UploadingState.Uploading) + return try { + val result = + BlossomUploader() + .uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + serverBaseUrl = serverBaseUrl, + forceProxy = account::shouldUseTorForNIP96, + httpAuth = account::createBlossomUploadAuth, + context = context, + ) + + verifyHeader( + uploadResult = result, + localContentType = contentType, + forceProxy = account::shouldUseTorForNIP96, + originalHash = originalHash, + originalContentType = contentTypeForResult, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName) + } + } + + private suspend fun verifyHeader( + uploadResult: MediaUploadResult, + localContentType: String?, + originalContentType: String?, + originalHash: String?, + forceProxy: (String) -> Boolean, + ): UploadingFinalState { + if (uploadResult.url.isNullOrBlank()) { + return error(R.string.server_did_not_provide_a_url_after_uploading) + } + + updateState(0.6, UploadingState.Downloading) + + val imageData: ByteArray? = ImageDownloader().waitAndGetImage(uploadResult.url, forceProxy(uploadResult.url)) + + if (imageData != null) { + updateState(0.8, UploadingState.Hashing) + + val result = + FileHeader.prepare( + imageData, + uploadResult.type ?: localContentType, + uploadResult.dimension, + ) + + result.fold( + onSuccess = { + return finish( + OrchestratorResult.ServerResult( + it, + uploadResult.url, + uploadResult.magnet, + uploadResult.sha256, + originalContentType, + originalHash, + ), + ) + }, + onFailure = { + return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName) + }, + ) + } else { + return error(R.string.could_not_download_from_the_server) + } + } + + sealed class OrchestratorResult { + class NIP95Result( + val fileHeader: FileHeader, + val bytes: ByteArray, + val mimeTypeBeforeEncryption: String?, + val hashBeforeEncryption: String?, + ) : OrchestratorResult() + + class ServerResult( + val fileHeader: FileHeader, + val url: String, + val magnet: String?, + val uploadedHash: String?, + val mimeTypeBeforeEncryption: String?, + val hashBeforeEncryption: String?, + ) : OrchestratorResult() + } + + suspend fun compressIfNeeded( + uri: Uri, + mimeType: String?, + compressionQuality: CompressorQuality, + context: Context, + ) = if (compressionQuality != CompressorQuality.UNCOMPRESSED) { + updateState(0.02, UploadingState.Compressing) + MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext) + } else { + MediaCompressorResult(uri, mimeType, null) + } + + suspend fun upload( + uri: Uri, + mimeType: String?, + alt: String?, + sensitiveContent: Boolean, + compressionQuality: CompressorQuality, + server: ServerName, + account: Account, + context: Context, + ): UploadingFinalState { + val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context) + + 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) + } + } + + suspend fun uploadEncrypted( + uri: Uri, + mimeType: String?, + alt: String?, + sensitiveContent: Boolean, + compressionQuality: CompressorQuality, + encrypt: NostrCipher, + server: ServerName, + account: Account, + context: Context, + ): UploadingFinalState { + val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context) + val encrypted = EncryptFiles().encryptFile(context, compressed.uri, encrypt) + + 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) + } + } +} 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 new file mode 100644 index 000000000..e19502ff3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -0,0 +1,234 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads.blossom + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.HttpStatusMessages +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.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.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.events.BlossomAuthorizationEvent +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import java.io.File +import java.io.InputStream +import java.util.Base64 + +class BlossomUploader { + fun Context.getFileName(uri: Uri): String? = + when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) + else -> uri.path?.let(::File)?.name + } + + private fun Context.getContentFileName(uri: Uri): String? = + runCatching { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString) + } + }.getOrNull() + + suspend fun uploadImage( + uri: Uri, + contentType: String?, + size: Long?, + alt: String?, + sensitiveContent: String?, + serverBaseUrl: String, + forceProxy: (String) -> Boolean, + httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?, + context: Context, + ): MediaUploadResult { + checkNotInMainThread() + + val contentResolver = context.contentResolver + val myContentType = contentType ?: contentResolver.getType(uri) + val fileName = context.getFileName(uri) + + val imageInputStreamForHash = contentResolver.openInputStream(uri) + val payload = + imageInputStreamForHash?.use { + it.readBytes() + } + + checkNotNull(payload) { "Can't open the image input stream" } + + val hash = CryptoUtils.sha256(payload).toHexKey() + + val imageInputStream = contentResolver.openInputStream(uri) + + checkNotNull(imageInputStream) { "Can't open the image input stream" } + + return uploadImage( + imageInputStream, + hash, + payload.size, + fileName, + myContentType, + alt, + sensitiveContent, + serverBaseUrl, + forceProxy, + httpAuth, + context, + ) + } + + fun encodeAuth(event: BlossomAuthorizationEvent): String { + val encodedNIP98Event = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) + return "Nostr $encodedNIP98Event" + } + + suspend fun uploadImage( + inputStream: InputStream, + hash: HexKey, + length: Int, + baseFileName: String?, + contentType: String?, + alt: String?, + sensitiveContent: String?, + serverBaseUrl: String, + forceProxy: (String) -> Boolean, + httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?, + context: Context, + ): MediaUploadResult { + checkNotInMainThread() + + val fileName = baseFileName ?: randomChars() + val extension = + contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + + val client = HttpClientManager.getHttpClient(forceProxy(apiUrl)) + val requestBuilder = Request.Builder() + + val requestBody: RequestBody = + object : RequestBody() { + override fun contentType() = contentType?.toMediaType() + + override fun contentLength() = length.toLong() + + override fun writeTo(sink: BufferedSink) { + inputStream.source().use(sink::writeAll) + } + } + + httpAuth(hash, length.toLong(), alt?.let { "Uploading $it" } ?: "Uploading $fileName")?.let { + requestBuilder.addHeader("Authorization", encodeAuth(it)) + } + + contentType?.let { requestBuilder.addHeader("Content-Type", it) } + + requestBuilder + .addHeader("Content-Length", length.toString()) + .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(apiUrl) + .put(requestBody) + + val request = requestBuilder.build() + + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body.use { body -> + val str = body.string() + val result = parseResults(str) + return result + } + } else { + val errorMessage = response.headers.get("X-Reason") + + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (errorMessage != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage)) + } else if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString())) + } + } + } + } + + fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://") + + suspend fun delete( + hash: String, + contentType: String?, + serverBaseUrl: String, + forceProxy: (String) -> Boolean, + httpAuth: (hash: HexKey, alt: String) -> BlossomAuthorizationEvent?, + context: Context, + ): Boolean { + val extension = + contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + + val apiUrl = serverBaseUrl + + val requestBuilder = Request.Builder() + + httpAuth(hash, "Deleting $hash")?.let { + requestBuilder.addHeader("Authorization", encodeAuth(it)) + } + + val request = + requestBuilder + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(apiUrl.removeSuffix("/") + "/$hash.$extension") + .delete() + .build() + + HttpClientManager.getHttpClient(forceProxy(apiUrl)).newCall(request).execute().use { response -> + if (response.isSuccessful) { + return true + } else { + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + } + } + } + } + + private fun parseResults(body: String): MediaUploadResult { + val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, MediaUploadResult::class.java) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Result.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Result.kt new file mode 100644 index 000000000..9254d2b22 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Result.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.service.uploads.nip96 + +import com.fasterxml.jackson.annotation.JsonProperty + +data class Nip96Result( + val status: String? = null, + val message: String? = null, + @JsonProperty("processing_url") + val processingUrl: String? = null, + val percentage: Int? = null, + @JsonProperty("nip94_event") + val nip94Event: PartialEvent? = null, +) + +class PartialEvent( + val tags: Array>? = null, + val content: String? = null, +) + +data class DeleteResult( + val status: String?, + val message: String?, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 4db170ef7..7ff2eae37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.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.service +package com.vitorpamplona.amethyst.service.uploads.nip96 import android.content.ContentResolver import android.content.Context @@ -26,17 +26,18 @@ import android.net.Uri import android.provider.OpenableColumns import android.webkit.MimeTypeMap import androidx.core.net.toFile -import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.HttpStatusMessages +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.ammolite.service.HttpClientManager +import com.vitorpamplona.quartz.encoders.Dimension +import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent import kotlinx.coroutines.delay -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeoutOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.Request @@ -45,47 +46,44 @@ import okio.BufferedSink import okio.source import java.io.InputStream import java.util.Base64 -import kotlin.coroutines.resume val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') fun randomChars() = List(16) { charPool.random() }.joinToString("") -class Nip96Uploader( - val account: Account?, -) { +class Nip96Uploader { suspend fun uploadImage( uri: Uri, contentType: String?, size: Long?, alt: String?, sensitiveContent: String?, - server: Nip96MediaServers.ServerName, - contentResolver: ContentResolver, + serverBaseUrl: String, forceProxy: (String) -> Boolean, onProgress: (percentage: Float) -> Unit, + httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?, context: Context, - ): PartialEvent { - val serverInfo = - Nip96Retriever() - .loadInfo( - server.baseUrl, - forceProxy(server.baseUrl), - ) + ) = uploadImage( + uri, + contentType, + size, + alt, + sensitiveContent, + ServerInfoRetriever().loadInfo(serverBaseUrl, forceProxy(serverBaseUrl)), + forceProxy, + onProgress, + httpAuth, + context, + ) - return uploadImage( - uri, - contentType, - size, - alt, - sensitiveContent, - serverInfo, - contentResolver, - forceProxy, - onProgress, - context, - ) - } + fun ContentResolver.querySize(uri: Uri) = + query(uri, null, null, null, null)?.use { + it.moveToFirst() + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + it.getLong(sizeIndex) + } + + fun fileSize(uri: Uri) = runCatching { uri.toFile().length() }.getOrNull() suspend fun uploadImage( uri: Uri, @@ -93,25 +91,19 @@ class Nip96Uploader( size: Long?, alt: String?, sensitiveContent: String?, - server: Nip96Retriever.ServerInfo, - contentResolver: ContentResolver, + server: ServerInfo, forceProxy: (String) -> Boolean, onProgress: (percentage: Float) -> Unit, + httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?, context: Context, - ): PartialEvent { + ): MediaUploadResult { checkNotInMainThread() + val contentResolver = context.contentResolver val myContentType = contentType ?: contentResolver.getType(uri) - val imageInputStream = contentResolver.openInputStream(uri) + val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0 - val length = - size - ?: contentResolver.query(uri, null, null, null, null)?.use { - it.moveToFirst() - val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) - it.getLong(sizeIndex) - } - ?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0 + val imageInputStream = contentResolver.openInputStream(uri) checkNotNull(imageInputStream) { "Can't open the image input stream" } @@ -124,6 +116,7 @@ class Nip96Uploader( server, forceProxy, onProgress, + httpAuth, context, ) } @@ -134,16 +127,16 @@ class Nip96Uploader( contentType: String?, alt: String?, sensitiveContent: String?, - server: Nip96Retriever.ServerInfo, + server: ServerInfo, forceProxy: (String) -> Boolean, onProgress: (percentage: Float) -> Unit, + httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?, context: Context, - ): PartialEvent { + ): MediaUploadResult { checkNotInMainThread() val fileName = randomChars() - val extension = - contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl)) val requestBuilder = Request.Builder() @@ -172,7 +165,7 @@ class Nip96Uploader( }, ).build() - nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } + httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", encodeAuth(it)) } requestBuilder .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") @@ -190,9 +183,9 @@ class Nip96Uploader( if (!result.processingUrl.isNullOrBlank()) { return waitProcessing(result, server, forceProxy, onProgress) } else if (result.status == "success" && result.nip94Event != null) { - return result.nip94Event + return convertToMediaResult(result.nip94Event) } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message)) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) } } } else { @@ -214,21 +207,63 @@ class Nip96Uploader( val explanation = HttpStatusMessages.resourceIdFor(response.code) if (errorMessage != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage)) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage)) } else if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation))) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation))) } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code)) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString())) } } } } + fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://") + + fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult { + // Images don't seem to be ready immediately after upload + val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) + val remoteMimeType = + nip96.tags + ?.firstOrNull { it.size > 1 && it[0] == "m" } + ?.get(1) + ?.ifBlank { null } + val hash = + nip96.tags + ?.firstOrNull { it.size > 1 && it[0] == "x" } + ?.get(1) + ?.ifBlank { null } + val dim = + nip96.tags + ?.firstOrNull { it.size > 1 && it[0] == "dim" } + ?.get(1) + ?.ifBlank { null } + ?.let { Dimension.parse(it) } + val magnet = + nip96.tags + ?.firstOrNull { it.size > 1 && it[0] == "magnet" } + ?.get(1) + ?.ifBlank { null } + + return MediaUploadResult( + url = imageUrl, + type = remoteMimeType, + sha256 = hash, + dimension = dim, + magnet = magnet, + ) + } + + fun encodeAuth(event: HTTPAuthorizationEvent): String { + val encodedNIP98Event = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) + return "Nostr $encodedNIP98Event" + } + suspend fun delete( hash: String, contentType: String?, - server: Nip96Retriever.ServerInfo, + server: ServerInfo, forceProxy: (String) -> Boolean, + httpAuth: (String, String, ByteArray?) -> HTTPAuthorizationEvent, context: Context, ): Boolean { val extension = @@ -238,7 +273,7 @@ class Nip96Uploader( val requestBuilder = Request.Builder() - nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } + httpAuth(server.apiUrl, "DELETE", null)?.let { requestBuilder.addHeader("Authorization", encodeAuth(it)) } val request = requestBuilder @@ -267,10 +302,10 @@ class Nip96Uploader( private suspend fun waitProcessing( result: Nip96Result, - server: Nip96Retriever.ServerInfo, + server: ServerInfo, forceProxy: (String) -> Boolean, onProgress: (percentage: Float) -> Unit, - ): PartialEvent { + ): MediaUploadResult { var currentResult = result while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { @@ -297,65 +332,19 @@ class Nip96Uploader( val nip94 = currentResult.nip94Event if (nip94 != null) { - return nip94 + return convertToMediaResult(nip94) } else { throw RuntimeException("Error waiting for processing. Final result is unavailable") } } - suspend fun nip98Header(url: String): String? = - withTimeoutOrNull(5000) { - suspendCancellableCoroutine { continuation -> - nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) } - } - } - - fun nip98Header( - url: String, - method: String, - file: ByteArray? = null, - onReady: (String?) -> Unit, - ) { - val myAccount = account - - if (myAccount == null) { - onReady(null) - return - } - - myAccount.createHTTPAuthorization(url, method, file) { - val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray()) - onReady("Nostr $encodedNIP98Event") - } - } - - data class DeleteResult( - val status: String?, - val message: String?, - ) - private fun parseDeleteResults(body: String): DeleteResult { - val mapper = - jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) return mapper.readValue(body, DeleteResult::class.java) } - data class Nip96Result( - val status: String? = null, - val message: String? = null, - @JsonProperty("processing_url") val processingUrl: String? = null, - val percentage: Int? = null, - @JsonProperty("nip94_event") val nip94Event: PartialEvent? = null, - ) - - class PartialEvent( - val tags: Array>? = null, - val content: String? = null, - ) - private fun parseResults(body: String): Nip96Result { - val mapper = - jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) return mapper.readValue(body, Nip96Result::class.java) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfo.kt new file mode 100644 index 000000000..5f811b4ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfo.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads.nip96 + +import com.fasterxml.jackson.annotation.JsonProperty + +typealias PlanName = String + +typealias MimeType = String + +data class ServerInfo( + @JsonProperty("api_url") + val apiUrl: String, + @JsonProperty("download_url") + val downloadUrl: String? = null, + @JsonProperty("delegated_to_url") + val delegatedToUrl: String? = null, + @JsonProperty("supported_nips") + val supportedNips: ArrayList = arrayListOf(), + @JsonProperty("tos_url") val + tosUrl: String? = null, + @JsonProperty("content_types") val + contentTypes: ArrayList = arrayListOf(), + @JsonProperty("plans") val + plans: Map = mapOf(), +) + +data class Plan( + @JsonProperty("name") val + name: String? = null, + @JsonProperty("is_nip98_required") val + isNip98Required: Boolean? = null, + @JsonProperty("url") val + url: String? = null, + @JsonProperty("max_byte_size") val + maxByteSize: Long? = null, + @JsonProperty("file_expiration") val + fileExpiration: ArrayList = arrayListOf(), + @JsonProperty("media_transformations") + val mediaTransformations: Map> = emptyMap(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt similarity index 58% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt index b0f7ffd69..e74daf4e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt @@ -18,69 +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.amethyst.service +package com.vitorpamplona.amethyst.service.uploads.nip96 import android.util.Log -import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import kotlinx.coroutines.CancellationException import okhttp3.Request import java.net.URI import java.net.URL -object Nip96MediaServers { - val DEFAULT = - listOf( - ServerName("Nostr.Build", "https://nostr.build"), - ServerName("NostrCheck.me", "https://nostrcheck.me"), - ServerName("NostPic", "https://nostpic.com"), - ServerName("Sovbit", "https://files.sovbit.host"), - ServerName("Void.cat", "https://void.cat"), - ) - - data class ServerName( - val name: String, - val baseUrl: String, - ) - - val cache: MutableMap = mutableMapOf() - - suspend fun load( - url: String, - forceProxy: Boolean, - ): Nip96Retriever.ServerInfo { - val cached = cache[url] - if (cached != null) return cached - - val fetched = Nip96Retriever().loadInfo(url, forceProxy) - cache[url] = fetched - return fetched - } -} - -class Nip96Retriever { - data class ServerInfo( - @JsonProperty("api_url") val apiUrl: String, - @JsonProperty("download_url") val downloadUrl: String? = null, - @JsonProperty("delegated_to_url") val delegatedToUrl: String? = null, - @JsonProperty("supported_nips") val supportedNips: ArrayList = arrayListOf(), - @JsonProperty("tos_url") val tosUrl: String? = null, - @JsonProperty("content_types") val contentTypes: ArrayList = arrayListOf(), - @JsonProperty("plans") val plans: Map = mapOf(), - ) - - data class Plan( - @JsonProperty("name") val name: String? = null, - @JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null, - @JsonProperty("url") val url: String? = null, - @JsonProperty("max_byte_size") val maxByteSize: Long? = null, - @JsonProperty("file_expiration") val fileExpiration: ArrayList = arrayListOf(), - @JsonProperty("media_transformations") - val mediaTransformations: Map> = emptyMap(), - ) - +class ServerInfoRetriever { fun parse( baseUrl: String, body: String, @@ -136,23 +86,19 @@ class Nip96Retriever { } } } -} -typealias PlanName = String - -typealias MimeType = String - -fun makeAbsoluteIfRelativeUrl( - baseUrl: String, - potentialyRelativeUrl: String, -): String = - try { - val apiUrl = URI(potentialyRelativeUrl) - if (apiUrl.isAbsolute) { + fun makeAbsoluteIfRelativeUrl( + baseUrl: String, + potentialyRelativeUrl: String, + ): String = + try { + val apiUrl = URI(potentialyRelativeUrl) + if (apiUrl.isAbsolute) { + potentialyRelativeUrl + } else { + URL(URL(baseUrl), potentialyRelativeUrl).toString() + } + } catch (e: Exception) { potentialyRelativeUrl - } else { - URL(URL(baseUrl), potentialyRelativeUrl).toString() } - } catch (e: Exception) { - potentialyRelativeUrl - } +} 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 3c6daa69f..77908f03a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -20,9 +20,6 @@ */ package com.vitorpamplona.amethyst.ui -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities @@ -31,7 +28,6 @@ import android.os.Bundle import android.util.Log import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.LaunchedEffect @@ -43,6 +39,7 @@ import com.vitorpamplona.amethyst.debugState import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex import com.vitorpamplona.amethyst.ui.navigation.Route @@ -50,7 +47,6 @@ 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.ammolite.service.HttpClientManager import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.Nip47WalletConnect import com.vitorpamplona.quartz.events.ChannelCreateEvent @@ -259,30 +255,12 @@ class MainActivity : AppCompatActivity() { } } -class GetMediaActivityResultContract : ActivityResultContracts.GetContent() { - @SuppressLint("MissingSuperCall") - override fun createIntent( - context: Context, - input: String, - ): Intent { - // Force only images and videos to be selectable - // Force OPEN Document because of the resulting URI must be passed to the - // Playback service and the picker's permissions only allow the activity to read the URI - return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - // Force only images and videos to be selectable - type = "*/*" - putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) - } - } -} - fun uriToRoute(uri: String?): String? = - if (uri.equals("nostr:Notifications", true)) { + if (uri?.startsWith("notifications", true) == true || uri?.startsWith("nostr:notifications", true) == true) { Route.Notification.route.replace("{scrollToTop}", "true") } else { - if (uri?.startsWith("nostr:Hashtag?id=") == true) { - Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id=")) + if (uri?.startsWith("hashtag?id=") == true || uri?.startsWith("nostr:hashtag?id=") == true) { + Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:").removePrefix("hashtag?id=")) } else { val nip19 = Nip19Bech32.uriToRoute(uri)?.entity when (nip19) { @@ -302,6 +280,7 @@ fun uriToRoute(uri: String?): String? = "Event/${nip19.hex}" } } + is Nip19Bech32.NAddress -> { if (nip19.kind == CommunityDefinitionEvent.KIND) { "Community/${nip19.atag}" @@ -311,12 +290,14 @@ fun uriToRoute(uri: String?): String? = "Event/${nip19.atag}" } } + is Nip19Bech32.NEmbed -> { if (LocalCache.getNoteIfExists(nip19.event.id) == null) { LocalCache.verifyAndConsume(nip19.event, null) } "Event/${nip19.event.id}" } + else -> null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 4cd14c191..6aa25517e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -90,6 +90,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview @@ -301,7 +303,7 @@ fun EditPostView( myUrlPreview, mimeType = null, roundedCorner = true, - isFiniteHeight = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) } else { @@ -325,22 +327,22 @@ fun EditPostView( } } - val url = postViewModel.contentToAddUrl - if (url != null) { + postViewModel.multiOrchestrator?.let { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { ImageVideoDescription( - url, + it, accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent, mediaQuality -> - postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) - if (!server.isNip95) { - accountViewModel.account.settings.changeDefaultFileServer(server.server) + postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) } }, - onCancel = { postViewModel.contentToAddUrl = null }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } }, accountViewModel = accountViewModel, ) @@ -467,7 +469,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) { .height(50.dp), verticalAlignment = CenterVertically, ) { - UploadFromGallery( + SelectFromGallery( isUploading = postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, 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 788882e92..fdf1eda15 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 @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.Context -import android.net.Uri -import android.util.Log import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -38,17 +36,21 @@ 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.FileHeader -import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.ui.components.MediaCompressor +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.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +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.events.FileHeaderEvent +import com.vitorpamplona.quartz.encoders.IMetaTag +import com.vitorpamplona.quartz.encoders.IMetaTagBuilder import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent -import kotlinx.coroutines.CancellationException +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -61,7 +63,7 @@ open class EditPostViewModel : ViewModel() { var subject by mutableStateOf(TextFieldValue("")) - var nip94attachments by mutableStateOf>(emptyList()) + var iMetaAttachments by mutableStateOf>(emptyList()) var nip95attachments by mutableStateOf>>(emptyList()) @@ -74,7 +76,7 @@ open class EditPostViewModel : ViewModel() { var userSuggestionsMainMessage: UserSuggestionAnchor? = null // Images and Videos - var contentToAddUrl by mutableStateOf(null) + var multiOrchestrator by mutableStateOf(null) // Invoices var canAddInvoice by mutableStateOf(false) @@ -99,7 +101,7 @@ open class EditPostViewModel : ViewModel() { this.account = accountViewModel.account canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null - contentToAddUrl = null + multiOrchestrator = null message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "") urlPreview = findUrlInMessage() @@ -145,89 +147,77 @@ open class EditPostViewModel : ViewModel() { } fun upload( - galleryUri: Uri, alt: String?, sensitiveContent: Boolean, mediaQuality: Int, isPrivate: Boolean = false, - server: ServerOption, + server: ServerName, onError: (String, String) -> Unit, context: Context, ) { - isUploadingImage = true - contentToAddUrl = null + viewModelScope.launch { + val myAccount = account ?: return@launch + val myMultiOrchestrator = multiOrchestrator ?: return@launch - val contentResolver = context.contentResolver - val contentType = contentResolver.getType(galleryUri) + isUploadingImage = true - viewModelScope.launch(Dispatchers.IO) { - MediaCompressor() - .compress( - galleryUri, - contentType, - context.applicationContext, - onReady = { fileUri, contentType, size -> - if (server.isNip95) { - contentResolver.openInputStream(fileUri)?.use { - createNIP95Record( - it.readBytes(), - contentType, - alt, - sensitiveContent, - onError = { - onError(stringRes(context, R.string.failed_to_upload_media_no_details), it) - }, - context, - ) - } - } else { - viewModelScope.launch(Dispatchers.IO) { - try { - val result = - Nip96Uploader(account) - .uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = alt, - sensitiveContent = if (sensitiveContent) "" else null, - server = server.server, - contentResolver = contentResolver, - forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false }, - onProgress = {}, - context = context, - ) - - createNIP94Record( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false }, - onError = { - onError(stringRes(context, R.string.failed_to_upload_media_no_details), it) - }, - context = context, - ) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e( - "ImageUploader", - "Failed to upload ${e.message}", - e, - ) - isUploadingImage = false - onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) - } - } - } - }, - onError = { - isUploadingImage = false - onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it)) - }, - mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality), + val results = + myMultiOrchestrator.upload( + viewModelScope, + alt, + sensitiveContent, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + myAccount, + context, ) + + 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 -> + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + } + + urlPreview = findUrlInMessage() + } + } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMeta = + IMetaTagBuilder(state.result.url) + .apply { + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType + ?.let { mimeType(it) } + state.result.fileHeader.dim + ?.let { dims(it) } + state.result.fileHeader.blurHash + ?.let { blurhash(it.blurhash) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } + alt?.let { alt(it) } + if (sensitiveContent) sensitiveContent("") + }.build() + + iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta + + message = message.insertUrlAtCursor(state.result.url) + urlPreview = findUrlInMessage() + } + } + + this@EditPostViewModel.multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false } } @@ -237,7 +227,7 @@ open class EditPostViewModel : ViewModel() { editedFromNote = null - contentToAddUrl = null + multiOrchestrator = null urlPreview = null isUploadingImage = false @@ -272,11 +262,7 @@ open class EditPostViewModel : ViewModel() { if (lastWord.startsWith("@") && lastWord.length > 2) { NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) viewModelScope.launch(Dispatchers.IO) { - userSuggestions = - LocalCache - .findUsersStartingWith(lastWord.removePrefix("@"), account) - .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) - .reversed() + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"), account) } } else { NostrSearchEventOrUserDataSource.clear() @@ -309,117 +295,13 @@ open class EditPostViewModel : ViewModel() { } } - fun canPost(): Boolean = - message.text.isNotBlank() && - !isUploadingImage && - !wantsInvoice && - contentToAddUrl == null + fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && multiOrchestrator == null - suspend fun createNIP94Record( - uploadingResult: Nip96Uploader.PartialEvent, - localContentType: String?, - alt: String?, - sensitiveContent: Boolean, - forceProxy: (String) -> Boolean, - onError: (String) -> Unit = {}, - context: Context, - ) { - // Images don't seem to be ready immediately after upload - val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - val remoteMimeType = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "m" } - ?.get(1) - ?.ifBlank { null } - val originalHash = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "ox" } - ?.get(1) - ?.ifBlank { null } - val dim = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "dim" } - ?.get(1) - ?.ifBlank { null } - val magnet = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "magnet" } - ?.get(1) - ?.ifBlank { null } - - if (imageUrl.isNullOrBlank()) { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - isUploadingImage = false - onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) - return - } - - FileHeader.prepare( - fileUrl = imageUrl, - mimeType = remoteMimeType ?: localContentType, - dimPrecomputed = dim, - forceProxy = forceProxy(imageUrl), - onReady = { header: FileHeader -> - account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> - isUploadingImage = false - nip94attachments = nip94attachments + event - - message = message.insertUrlAtCursor(imageUrl) - urlPreview = findUrlInMessage() - } - }, - onError = { - isUploadingImage = false - onError(stringRes(context, R.string.could_not_prepare_header, it)) - }, - ) + fun selectImage(uris: ImmutableList) { + multiOrchestrator = MultiOrchestrator(uris) } - fun createNIP95Record( - bytes: ByteArray, - mimeType: String?, - alt: String?, - sensitiveContent: Boolean, - onError: (String) -> Unit = {}, - context: Context, - ) { - if (bytes.size > 80000) { - viewModelScope.launch { - onError(stringRes(context, id = R.string.media_too_big_for_nip95)) - isUploadingImage = false - } - return - } - - viewModelScope.launch(Dispatchers.IO) { - FileHeader.prepare( - bytes, - mimeType, - null, - onReady = { - account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - - isUploadingImage = false - - note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - } - - urlPreview = findUrlInMessage() - } - }, - onError = { - isUploadingImage = false - onError(stringRes(context, R.string.could_not_prepare_header, it)) - }, - ) - } - } - - fun selectImage(uri: Uri) { - contentToAddUrl = uri + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt deleted file mode 100644 index 6a2fb7408..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions - -import com.vitorpamplona.ammolite.service.HttpClientManager -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.delay -import java.net.HttpURLConnection -import java.net.URL - -class ImageDownloader { - suspend fun waitAndGetImage( - imageUrl: String, - forceProxy: Boolean, - ): ByteArray? { - var imageData: ByteArray? = null - var tentatives = 0 - - // Servers are usually not ready.. so tries to download it for 15 times/seconds. - while (imageData == null && tentatives < 15) { - imageData = - try { - // TODO: Migrate to OkHttp - HttpURLConnection.setFollowRedirects(true) - var url = URL(imageUrl) - var huc = - if (forceProxy) { - url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection - } else { - url.openConnection() as HttpURLConnection - } - huc.instanceFollowRedirects = true - var responseCode = huc.responseCode - - if (responseCode in 300..400) { - val newUrl: String = huc.getHeaderField("Location") - - // open the new connnection again - url = URL(newUrl) - huc = - if (forceProxy) { - url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection - } else { - url.openConnection() as HttpURLConnection - } - responseCode = huc.responseCode - } - - if (responseCode in 200..300) { - huc.inputStream.use { it.readBytes() } - } else { - tentatives++ - delay(1000) - - null - } - } catch (e: Exception) { - if (e is CancellationException) throw e - tentatives++ - delay(1000) - null - } - } - - return imageData - } -} 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 f93837adb..6bf5b480d 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 @@ -32,7 +32,7 @@ import androidx.annotation.RequiresApi import androidx.core.net.toFile import androidx.core.net.toUri import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import kotlinx.coroutines.CancellationException import okhttp3.Call import okhttp3.Callback 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 4a9f4c080..b38c72f73 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 @@ -21,324 +21,201 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.Context -import android.net.Uri -import android.util.Log 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.FileHeader -import com.vitorpamplona.amethyst.service.Nip96MediaServers -import com.vitorpamplona.amethyst.service.Nip96Uploader -import com.vitorpamplona.amethyst.ui.components.MediaCompressor +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.ammolite.relays.RelaySetupInfo -import kotlinx.coroutines.CancellationException +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch - -data class ServerOption( - val server: Nip96MediaServers.ServerName, - val isNip95: Boolean, -) +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.resume @Stable open class NewMediaModel : ViewModel() { var account: Account? = null var isUploadingImage by mutableStateOf(false) - var mediaType by mutableStateOf(null) - var selectedServer by mutableStateOf(null) - var alt by mutableStateOf("") + var selectedServer by mutableStateOf(null) + var caption by mutableStateOf("") var sensitiveContent by mutableStateOf(false) // Images and Videos - var galleryUri by mutableStateOf(null) - - var uploadingPercentage = mutableStateOf(0.0f) - var uploadingDescription = mutableStateOf(null) - + var multiOrchestrator by mutableStateOf(null) var onceUploaded: () -> Unit = {} + // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED + var mediaQualitySlider by mutableIntStateOf(1) + open fun load( account: Account, - uri: Uri, - contentType: String?, + uris: ImmutableList, ) { + this.caption = "" this.account = account - this.galleryUri = uri - this.mediaType = contentType - this.selectedServer = ServerOption(defaultServer(), false) + this.multiOrchestrator = MultiOrchestrator(uris) + this.selectedServer = defaultServer() } + fun isImage( + url: String, + mimeType: String?, + ): Boolean = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url) + fun upload( context: Context, relayList: List, - mediaQuality: Int, - onError: (String) -> Unit = {}, + onError: (String, String) -> Unit, ) { - isUploadingImage = true + viewModelScope.launch { + val myAccount = account ?: return@launch + if (relayList.isEmpty()) return@launch + val serverToUse = selectedServer ?: return@launch - val contentResolver = context.contentResolver - val myGalleryUri = galleryUri ?: return - val serverToUse = selectedServer ?: return + val myMultiOrchestrator = multiOrchestrator ?: return@launch - val contentType = contentResolver.getType(myGalleryUri) + isUploadingImage = true - viewModelScope.launch(Dispatchers.IO) { - uploadingPercentage.value = 0.1f - uploadingDescription.value = "Compress" - MediaCompressor() - .compress( - myGalleryUri, - contentType, - context.applicationContext, - onReady = { fileUri, contentType, size -> - if (serverToUse.isNip95) { - uploadingPercentage.value = 0.2f - uploadingDescription.value = "Loading" - contentResolver.openInputStream(fileUri)?.use { - createNIP95Record( - it.readBytes(), - contentType, - alt, - sensitiveContent, - relayList = relayList, - onError = onError, - context, - ) - } - ?: run { - viewModelScope.launch { - onError(stringRes(context, R.string.could_not_open_the_compressed_file)) - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - } - } + val results = + myMultiOrchestrator.upload( + viewModelScope, + caption, + sensitiveContent, + MediaCompressor.intToCompressorQuality(mediaQualitySlider), + serverToUse, + myAccount, + context, + ) + + if (results.allGood) { + // It all finished successfully + val nip95s = + results.successful.mapNotNull { + it.result as? UploadOrchestrator.OrchestratorResult.NIP95Result + } + + val videosAndOthers = + results.successful.mapNotNull { + val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult + if (map != null && !isImage(map.url, map.fileHeader.mimeType)) { + map } else { - uploadingPercentage.value = 0.2f - uploadingDescription.value = "Uploading" - viewModelScope.launch(Dispatchers.IO) { - try { - val result = - Nip96Uploader(account) - .uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = alt, - sensitiveContent = if (sensitiveContent) "" else null, - server = serverToUse.server, - contentResolver = contentResolver, - forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false }, - onProgress = { percent: Float -> - uploadingPercentage.value = 0.2f + (0.2f * percent) - }, - context = context, - ) + null + } + } - createNIP94Record( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - relayList = relayList, - forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false }, - onError = onError, - context, - ) - } catch (e: Exception) { - if (e is CancellationException) throw e - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - onError(stringRes(context, R.string.failed_to_upload_media, e.message)) + val imageUrls = + results.successful + .mapNotNull { + val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult + if (map != null && isImage(map.url, map.fileHeader.mimeType)) { + Pair(map.url, map.fileHeader) + } else { + null + } + }.toMap() + + val nip95jobs = + nip95s.map { + // upload each file as an individual nip95 event. + viewModelScope.launch(Dispatchers.IO) { + withTimeoutOrNull(30000) { + suspendCancellableCoroutine { continuation -> + account?.createNip95(it.bytes, headerInfo = it.fileHeader, caption, sensitiveContent) { nip95 -> + account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) + continuation.resume(true) + } } } } - }, - onError = { - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - onError(stringRes(context, R.string.error_when_compressing_media, it)) - }, - mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality), - ) - } - } - - open fun cancel() { - galleryUri = null - isUploadingImage = false - mediaType = null - uploadingDescription.value = null - uploadingPercentage.value = 0.0f - - alt = "" - selectedServer = ServerOption(defaultServer(), false) - } - - fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null - - suspend fun createNIP94Record( - uploadingResult: Nip96Uploader.PartialEvent, - localContentType: String?, - alt: String, - sensitiveContent: Boolean, - relayList: List, - forceProxy: (String) -> Boolean, - onError: (String) -> Unit = {}, - context: Context, - ) { - uploadingPercentage.value = 0.40f - uploadingDescription.value = "Server Processing" - // Images don't seem to be ready immediately after upload - - val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - val remoteMimeType = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "m" } - ?.get(1) - ?.ifBlank { null } - val originalHash = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "ox" } - ?.get(1) - ?.ifBlank { null } - val dim = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "dim" } - ?.get(1) - ?.ifBlank { null } - val magnet = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "magnet" } - ?.get(1) - ?.ifBlank { null } - - if (imageUrl.isNullOrBlank()) { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) - return - } - - uploadingDescription.value = "Downloading" - uploadingPercentage.value = 0.60f - - val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl, forceProxy(imageUrl)) - - if (imageData != null) { - uploadingPercentage.value = 0.80f - uploadingDescription.value = "Hashing" - - FileHeader.prepare( - data = imageData, - mimeType = remoteMimeType ?: localContentType, - dimPrecomputed = dim, - onReady = { - uploadingPercentage.value = 0.90f - uploadingDescription.value = "Sending" - account?.sendHeader( - imageUrl, - magnet, - it, - alt, - sensitiveContent, - originalHash, - relayList, - ) { - uploadingPercentage.value = 1.00f - isUploadingImage = false - onceUploaded() - cancel() } - }, - onError = { - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - onError(stringRes(context, R.string.could_not_prepare_local_file_to_upload, it)) - }, - ) - } else { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - onError(stringRes(context, R.string.could_not_download_from_the_server)) - } - } - fun createNIP95Record( - bytes: ByteArray, - mimeType: String?, - alt: String, - sensitiveContent: Boolean, - relayList: List, - onError: (String) -> Unit = {}, - context: Context, - ) { - if (bytes.size > 80000) { - viewModelScope.launch { - onError(stringRes(context, id = R.string.media_too_big_for_nip95)) - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null + val videoJobs = + videosAndOthers.map { + // upload each file as an individual nip95 event. + viewModelScope.launch(Dispatchers.IO) { + withTimeoutOrNull(30000) { + suspendCancellableCoroutine { continuation -> + account?.sendHeader( + it.url, + it.magnet, + it.fileHeader, + caption, + sensitiveContent, + it.uploadedHash, + relayList, + ) { + continuation.resume(true) + } + } + } + } + } + + val imageJobs = + listOf( + viewModelScope.launch(Dispatchers.IO) { + withTimeoutOrNull(30000) { + suspendCancellableCoroutine { continuation -> + account?.sendAllAsOnePictureEvent( + imageUrls, + caption, + sensitiveContent, + relayList, + ) { + continuation.resume(true) + } + } + } + }, + ) + + nip95jobs.joinAll() + videoJobs.joinAll() + imageJobs.joinAll() + + 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")) } - return - } - - uploadingPercentage.value = 0.30f - uploadingDescription.value = "Hashing" - - viewModelScope.launch(Dispatchers.IO) { - FileHeader.prepare( - bytes, - mimeType, - null, - onReady = { - uploadingDescription.value = "Signing" - uploadingPercentage.value = 0.40f - account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> - uploadingDescription.value = "Sending" - uploadingPercentage.value = 0.60f - account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) - - uploadingPercentage.value = 1.00f - isUploadingImage = false - onceUploaded() - cancel() - } - }, - onError = { - uploadingDescription.value = null - uploadingPercentage.value = 0.00f - isUploadingImage = false - cancel() - onError(stringRes(context, R.string.could_not_prepare_local_file_to_upload, it)) - }, - ) } } - fun isImage() = mediaType?.startsWith("image") + open fun cancelModel() { + multiOrchestrator = null + isUploadingImage = false + caption = "" + selectedServer = defaultServer() + } - fun isVideo() = mediaType?.startsWith("video") + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + multiOrchestrator?.remove(selected) + } - fun defaultServer() = account?.settings?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0] + fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null && selectedServer != null + + fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] fun onceUploaded(onceUploaded: () -> Unit) { this.onceUploaded = onceUploaded diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 5e3df001d..47f35746c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -20,25 +20,17 @@ */ package com.vitorpamplona.amethyst.ui.actions -import android.graphics.Bitmap -import android.net.Uri -import android.os.Build -import android.util.Log -import android.util.Size -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -55,17 +47,14 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf 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.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardCapitalization @@ -73,11 +62,11 @@ 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 coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.Nip96MediaServers +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton @@ -85,42 +74,35 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton 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.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.events.FileServersEvent +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewMediaView( - uri: Uri, + uris: ImmutableList, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: INav, ) { val account = accountViewModel.account - val resolver = LocalContext.current.contentResolver val context = LocalContext.current val scrollState = rememberScrollState() - LaunchedEffect(uri) { - val mediaType = resolver.getType(uri) ?: "" - postViewModel.load(account, uri, mediaType) + LaunchedEffect(uris) { + postViewModel.load(account, uris) } var showRelaysDialog by remember { mutableStateOf(false) } var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } - // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED - var mediaQualitySlider by remember { mutableIntStateOf(1) } - Dialog( onDismissRequest = { onClose() }, properties = @@ -149,7 +131,7 @@ fun NewMediaView( ) { Icon( painter = painterResource(R.drawable.relays), - contentDescription = null, + contentDescription = stringRes(id = R.string.relay_list_selector), modifier = Modifier.height(25.dp), tint = MaterialTheme.colorScheme.onBackground, ) @@ -159,12 +141,10 @@ fun NewMediaView( PostButton( onPost = { onClose() - postViewModel.upload(context, relayList, mediaQualitySlider) { - accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it) - } + postViewModel.upload(context, relayList, onError = accountViewModel::toast) postViewModel.selectedServer?.let { - if (!it.isNip95) { - account.settings.changeDefaultFileServer(it.server) + if (it.type != ServerType.NIP95) { + account.settings.changeDefaultFileServer(it) } } }, @@ -177,7 +157,7 @@ fun NewMediaView( Spacer(modifier = StdHorzSpacer) CloseButton( onPress = { - postViewModel.cancel() + postViewModel.cancelModel() onClose() }, ) @@ -207,69 +187,9 @@ fun NewMediaView( .consumeWindowInsets(pad) .imePadding(), ) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth().weight(1f).verticalScroll(scrollState), - ) { + Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { + Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { ImageVideoPost(postViewModel, accountViewModel) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) { - Column( - modifier = Modifier.weight(1.0f), - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - Text( - text = stringRes(context, R.string.media_compression_quality_label), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringRes(context, R.string.media_compression_quality_explainer), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 5, - overflow = TextOverflow.Ellipsis, - ) - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box(modifier = Modifier.fillMaxWidth()) { - Text( - text = - when (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) - 3 -> stringRes(R.string.media_compression_quality_uncompressed) - else -> stringRes(R.string.media_compression_quality_medium) - }, - modifier = Modifier.align(Alignment.Center), - ) - } - - Slider( - value = mediaQualitySlider.toFloat(), - onValueChange = { mediaQualitySlider = it.toInt() }, - valueRange = 0f..3f, - steps = 2, - ) - } - } } } } @@ -282,147 +202,106 @@ fun ImageVideoPost( postViewModel: NewMediaModel, accountViewModel: AccountViewModel, ) { - val listOfNip96ServersNote = - accountViewModel.account - .getFileServersNote() - .live() - .metadata - .observeAsState() - - val fileServers = - ( - (listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map { - ServerOption( - Nip96MediaServers.ServerName( - it, - it, - ), - false, - ) - } ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } - ) + - listOf( - ServerOption( - Nip96MediaServers.ServerName( - "NIP95", - stringRes(id = R.string.upload_server_relays_nip95), - ), - true, - ), - ) + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) + val fileServers by accountViewModel.account.liveServerList.collectAsState() val fileServerOptions = - remember { - fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() - } - val resolver = LocalContext.current.contentResolver - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) { - if (postViewModel.isImage() == true) { - AsyncImage( - model = postViewModel.galleryUri.toString(), - contentDescription = postViewModel.galleryUri.toString(), - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .padding(top = 4.dp) - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) - } else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - var bitmap by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = postViewModel.galleryUri) { - launch(Dispatchers.IO) { - postViewModel.galleryUri?.let { - try { - bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) - } + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) } - } - } - - bitmap?.let { - Image( - bitmap = it.asImageBitmap(), - contentDescription = "some useful description", - contentScale = ContentScale.FillWidth, - modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), - ) - } - } else { - postViewModel.galleryUri?.let { - VideoView( - videoUri = it.toString(), - mimeType = postViewModel.mediaType, - roundedCorner = false, - isFiniteHeight = false, - accountViewModel = accountViewModel, - ) - } + }.toImmutableList() } + + postViewModel.multiOrchestrator?.let { + ShowImageUploadGallery( + it, + postViewModel::deleteMediaToUpload, + accountViewModel, + ) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.add_caption)) }, + modifier = Modifier.fillMaxWidth().padding(top = 3.dp).height(150.dp), + maxLines = 10, + value = postViewModel.caption, + onValueChange = { postViewModel.caption = it }, + placeholder = { + Text( + text = stringRes(R.string.add_caption_example), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + SettingSwitchItem( + 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 }, + ) + + SettingsRow(R.string.file_server, R.string.file_server_description) { TextSpinner( - label = stringRes(id = R.string.file_server), + label = "", placeholder = fileServers - .firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer } - ?.server + .firstOrNull { it == accountViewModel.account.settings.defaultFileServer } ?.name - ?: fileServers[0].server.name, + ?: fileServers[0].name, options = fileServerOptions, onSelect = { postViewModel.selectedServer = fileServers[it] }, - modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), ) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), ) { - SettingSwitchItem( - modifier = Modifier.fillMaxWidth().padding(8.dp), - checked = postViewModel.sensitiveContent, - onCheckedChange = { postViewModel.sensitiveContent = it }, - title = R.string.add_sensitive_content_label, - description = R.string.add_sensitive_content_description, + Text( + text = stringRes(R.string.media_compression_quality_label), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.media_compression_quality_explainer), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 5, + overflow = TextOverflow.Ellipsis, ) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - value = postViewModel.alt, - onValueChange = { postViewModel.alt = it }, - placeholder = { - Text( - text = stringRes(R.string.content_description_example), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = + when (postViewModel.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) + 3 -> stringRes(R.string.media_compression_quality_uncompressed) + else -> stringRes(R.string.media_compression_quality_medium) + }, + modifier = Modifier.align(Alignment.Center), + ) + } + + Slider( + value = postViewModel.mediaQualitySlider.toFloat(), + onValueChange = { postViewModel.mediaQualitySlider = it.toInt() }, + valueRange = 0f..3f, + steps = 2, ) } } 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 710563b55..3d09153ca 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 @@ -39,29 +39,23 @@ class NewMessageTagger( var dao: Dao, ) { val directMentions = mutableSetOf() + val directMentionsNotes = mutableSetOf() + val directMentionsUsers = mutableSetOf() fun addUserToMentions(user: User) { + directMentionsUsers.add(user) directMentions.add(user.pubkeyHex) pTags = if (pTags?.contains(user) == true) pTags else pTags?.plus(user) ?: listOf(user) } fun addNoteToReplyTos(note: Note) { + directMentionsNotes.add(note) directMentions.add(note.idHex) note.author?.let { addUserToMentions(it) } eTags = if (eTags?.contains(note) == true) eTags else eTags?.plus(note) ?: listOf(note) } - fun tagIndex(user: User): Int { - // Postr Events assembles replies before mentions in the tag order - return (if (channelHex != null) 1 else 0) + (eTags?.size ?: 0) + (pTags?.indexOf(user) ?: 0) - } - - fun tagIndex(note: Note): Int { - // Postr Events assembles replies before mentions in the tag order - return (if (channelHex != null) 1 else 0) + (eTags?.indexOf(note) ?: 0) - } - suspend fun run() { // adds all references to mentions and reply tos message.split('\n').forEach { paragraph: String -> 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 1bd8df1da..7704bfa73 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 @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.Context -import android.net.Uri import android.util.Log import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -34,7 +33,6 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor @@ -43,45 +41,49 @@ 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.FileHeader -import com.vitorpamplona.amethyst.service.Nip96Uploader +import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.ui.components.MediaCompressor +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.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing 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.crypto.nip17.AESGCM import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag +import com.vitorpamplona.quartz.encoders.IMetaTagBuilder import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent -import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event -import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GitIssueEvent +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.PrivateDmEvent +import com.vitorpamplona.quartz.events.RootScope import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TorrentCommentEvent import com.vitorpamplona.quartz.events.TorrentEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.events.findURLs +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.UUID @@ -107,7 +109,7 @@ open class NewPostViewModel : ViewModel() { var pTags by mutableStateOf?>(null) var eTags by mutableStateOf?>(null) - var nip94attachments by mutableStateOf>(emptyList()) + var iMetaAttachments by mutableStateOf>(emptyList()) var nip95attachments by mutableStateOf>>(emptyList()) @@ -125,7 +127,7 @@ open class NewPostViewModel : ViewModel() { var subject by mutableStateOf(TextFieldValue("")) // Images and Videos - var contentToAddUrl by mutableStateOf(null) + var multiOrchestrator by mutableStateOf(null) // Polls var canUsePoll by mutableStateOf(false) @@ -166,7 +168,8 @@ open class NewPostViewModel : ViewModel() { // GeoHash var wantsToAddGeoHash by mutableStateOf(false) - var location: StateFlow? = null + var location: StateFlow? = null + var wantsExclusiveGeoPost by mutableStateOf(false) // ZapRaiser var canAddZapRaiser by mutableStateOf(false) @@ -222,7 +225,10 @@ open class NewPostViewModel : ViewModel() { if (replyNote.event !is CommunityDefinitionEvent) { replyNote.author?.let { replyUser -> val currentMentions = - (replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) } + (replyNote.event as? TextNoteEvent) + ?.mentions() + ?.filter { it.isNotEmpty() } + ?.map { LocalCache.getOrCreateUser(it) } ?: emptyList() if (currentMentions.contains(replyUser)) { @@ -241,7 +247,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 - contentToAddUrl = null + multiOrchestrator = null quote?.let { message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") @@ -325,7 +331,7 @@ open class NewPostViewModel : ViewModel() { canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null - contentToAddUrl = null + multiOrchestrator = null val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" } forwardZapTo = Split() @@ -338,7 +344,13 @@ open class NewPostViewModel : ViewModel() { wantsForwardZapTo = localfowardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.tags().any { it.size > 1 && it[0] == "content-warning" } - wantsToAddGeoHash = draftEvent.tags().any { it.size > 1 && it[0] == "g" } + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + if (geohash != null) { + wantsExclusiveGeoPost = draftEvent.kind() == CommentEvent.KIND + } + val zapraiser = draftEvent.tags().filter { it.size > 1 && it[0] == "zapraiser" } wantsZapraiser = zapraiser.isNotEmpty() zapRaiserAmount = null @@ -352,7 +364,7 @@ open class NewPostViewModel : ViewModel() { note } - if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) { + if (draftEvent !is PrivateDmEvent && draftEvent !is NIP17Group) { pTags = draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map { LocalCache.getOrCreateUser(it[1]) @@ -447,7 +459,7 @@ open class NewPostViewModel : ViewModel() { .firstOrNull() } ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW - wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent + wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is NIP17Group draftEvent.subject()?.let { subject = TextFieldValue() @@ -462,13 +474,13 @@ open class NewPostViewModel : ViewModel() { TextFieldValue(draftEvent.content()) } - requiresNIP17 = draftEvent is ChatMessageEvent - nip17 = draftEvent is ChatMessageEvent + requiresNIP17 = draftEvent is NIP17Group + nip17 = draftEvent is NIP17Group - if (draftEvent is ChatMessageEvent) { + if (draftEvent is NIP17Group) { toUsers = TextFieldValue( - draftEvent.recipientsPubKey().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, ) } @@ -532,7 +544,7 @@ open class NewPostViewModel : ViewModel() { null } - val geoHash = location?.value + val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null nip95attachments.forEach { @@ -542,11 +554,39 @@ open class NewPostViewModel : ViewModel() { } val urls = findURLs(tagger.message) - val usedAttachments = nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } - // Doesn't send as nip94 yet because we don't know if it makes sense. - // usedAttachments.forEach { account?.sendHeader(it, relayList, {}) } + val usedAttachments = iMetaAttachments.filter { it.url in urls.toSet() } - if (originalNote?.channelHex() != null) { + val replyingTo = originalNote + + 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, + 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, + draftTag = localDraft, + ) + } else if (originalNote?.channelHex() != null) { if (originalNote is AddressableEvent && originalNote?.address() != null) { account?.sendLiveMessage( message = tagger.message, @@ -557,7 +597,7 @@ open class NewPostViewModel : ViewModel() { wantsToMarkAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } else { @@ -569,8 +609,9 @@ open class NewPostViewModel : ViewModel() { zapReceiver = zapReceiver, wantsToMarkAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = localZapRaiserAmount, + directMentions = tagger.directMentions, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } @@ -584,21 +625,13 @@ open class NewPostViewModel : ViewModel() { wantsToMarkAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) - } else if (originalNote?.event is ChatMessageEvent) { - val receivers = - (originalNote?.event as ChatMessageEvent) - .recipientsPubKey() - .plus(originalNote?.author?.pubkeyHex) - .filterNotNull() - .toSet() - .toList() - + } else if (originalNote?.event is NIP17Group) { account?.sendNIP17PrivateMessage( message = tagger.message, - toUsers = receivers, + toUsers = (originalNote?.event as NIP17Group).groupMembers().toList(), subject = subject.text.ifBlank { null }, replyingTo = originalNote!!, mentions = tagger.pTags, @@ -606,7 +639,7 @@ open class NewPostViewModel : ViewModel() { zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } else if (!dmUsers.isNullOrEmpty()) { @@ -621,7 +654,7 @@ open class NewPostViewModel : ViewModel() { zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } else { @@ -634,7 +667,7 @@ open class NewPostViewModel : ViewModel() { zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } @@ -675,7 +708,7 @@ open class NewPostViewModel : ViewModel() { forkedFrom = forkedFromNote?.event as? Event, relayList = relayList, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } else if (originalNote?.event is TorrentCommentEvent) { @@ -714,7 +747,7 @@ open class NewPostViewModel : ViewModel() { forkedFrom = forkedFromNote?.event as? Event, relayList = relayList, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } @@ -743,7 +776,7 @@ open class NewPostViewModel : ViewModel() { forkedFrom = forkedFromNote?.event as? Event, relayList = relayList, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } else { @@ -762,7 +795,7 @@ open class NewPostViewModel : ViewModel() { zapRaiserAmount = localZapRaiserAmount, relayList = relayList, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } else if (wantsProduct) { @@ -781,7 +814,7 @@ open class NewPostViewModel : ViewModel() { zapRaiserAmount = localZapRaiserAmount, relayList = relayList, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } else { @@ -818,97 +851,151 @@ open class NewPostViewModel : ViewModel() { forkedFrom = forkedFromNote?.event as? Event, relayList = relayList, geohash = geoHash, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = localDraft, ) } } } + fun uploadAsSeparatePrivateEvent( + toUsers: Set, + alt: String?, + sensitiveContent: Boolean, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + val myAccount = account ?: return + + viewModelScope.launch(Dispatchers.Default) { + isUploadingImage = true + + val cipher = AESGCM() + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + val results = + myMultiOrchestrator.uploadEncrypted( + viewModelScope, + alt, + sensitiveContent, + MediaCompressor.intToCompressorQuality(mediaQuality), + cipher, + server, + myAccount, + context, + ) + + if (results.allGood) { + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + account?.sendNIP17EncryptedFile( + url = state.result.url, + toUsers = toUsers.toList(), + replyingTo = originalNote, + 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 = alt, + sensitiveContent = sensitiveContent, + ) + } + } + + multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false + } + } + fun upload( - galleryUri: Uri, alt: String?, sensitiveContent: Boolean, mediaQuality: Int, isPrivate: Boolean = false, - server: ServerOption, + server: ServerName, onError: (title: String, message: String) -> Unit, context: Context, ) { - isUploadingImage = true - contentToAddUrl = null + viewModelScope.launch(Dispatchers.Default) { + val myAccount = account ?: return@launch - val contentResolver = context.contentResolver - val contentType = contentResolver.getType(galleryUri) + val myMultiOrchestrator = multiOrchestrator ?: return@launch - viewModelScope.launch(Dispatchers.IO) { - MediaCompressor() - .compress( - galleryUri, - contentType, - context.applicationContext, - onReady = { fileUri, contentType, size -> - if (server.isNip95) { - contentResolver.openInputStream(fileUri)?.use { - createNIP95Record( - it.readBytes(), - contentType, - alt, - sensitiveContent, - onError = { - onError(stringRes(context, R.string.failed_to_upload_media_no_details), it) - }, - context, - ) - } - } else { - viewModelScope.launch(Dispatchers.IO) { - try { - val result = - Nip96Uploader(account) - .uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = alt, - sensitiveContent = if (sensitiveContent) "" else null, - server = server.server, - contentResolver = contentResolver, - forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false }, - onProgress = {}, - context = context, - ) + isUploadingImage = true - createNIP94Record( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false }, - onError = { - onError(stringRes(context, R.string.failed_to_upload_media_no_details), it) - }, - context = context, - ) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e( - "ImageUploader", - "Failed to upload ${e.message}", - e, - ) - isUploadingImage = false - onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) - } - } - } - }, - onError = { - isUploadingImage = false - onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it)) - }, - mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality), + val results = + myMultiOrchestrator.upload( + viewModelScope, + alt, + sensitiveContent, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + myAccount, + context, ) + + 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 -> + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + } + + urlPreview = findUrlInMessage() + } + } else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMeta = + IMetaTagBuilder(it.result.url) + .apply { + hash(it.result.fileHeader.hash) + size(it.result.fileHeader.size) + it.result.fileHeader.mimeType + ?.let { mimeType(it) } + it.result.fileHeader.dim + ?.let { dims(it) } + it.result.fileHeader.blurHash + ?.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("") + }.build() + + iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta + + message = message.insertUrlAtCursor(it.result.url) + urlPreview = findUrlInMessage() + } + } + + multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false } } @@ -919,7 +1006,7 @@ open class NewPostViewModel : ViewModel() { forkedFromNote = null - contentToAddUrl = null + multiOrchestrator = null urlPreview = null isUploadingImage = false pTags = null @@ -948,6 +1035,7 @@ open class NewPostViewModel : ViewModel() { wantsForwardZapTo = false wantsToMarkAsSensitive = false wantsToAddGeoHash = false + wantsExclusiveGeoPost = false forwardZapTo = Split() forwardZapToEditting = TextFieldValue("") @@ -966,6 +1054,10 @@ open class NewPostViewModel : ViewModel() { } } + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) + } + open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull() open fun removeFromReplyList(userToRemove: User) { @@ -993,13 +1085,10 @@ open class NewPostViewModel : ViewModel() { userSuggestionAnchor = it.selection userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE if (lastWord.startsWith("@") && lastWord.length > 2) { - NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + val prefix = lastWord.removePrefix("@") + NostrSearchEventOrUserDataSource.search(prefix) viewModelScope.launch(Dispatchers.IO) { - userSuggestions = - LocalCache - .findUsersStartingWith(lastWord.removePrefix("@"), account) - .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) - .reversed() + userSuggestions = LocalCache.findUsersStartingWith(prefix, account) } } else { NostrSearchEventOrUserDataSource.clear() @@ -1022,13 +1111,12 @@ open class NewPostViewModel : ViewModel() { userSuggestionAnchor = it.selection userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS if (lastWord.startsWith("@") && lastWord.length > 2) { - NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + val prefix = lastWord.removePrefix("@") + NostrSearchEventOrUserDataSource.search(prefix) viewModelScope.launch(Dispatchers.IO) { userSuggestions = LocalCache - .findUsersStartingWith(lastWord.removePrefix("@"), account) - .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) - .reversed() + .findUsersStartingWith(prefix, account) } } else { NostrSearchEventOrUserDataSource.clear() @@ -1050,18 +1138,11 @@ open class NewPostViewModel : ViewModel() { userSuggestionAnchor = it.selection userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS if (lastWord.length > 2) { - NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + val prefix = lastWord.removePrefix("@") + NostrSearchEventOrUserDataSource.search(prefix) viewModelScope.launch(Dispatchers.IO) { userSuggestions = - LocalCache - .findUsersStartingWith(lastWord.removePrefix("@"), account) - .sortedWith( - compareBy( - { account?.isFollowing(it) }, - { it.toBestDisplayName() }, - { it.pubkeyHex }, - ), - ).reversed() + LocalCache.findUsersStartingWith(prefix, account) } } else { NostrSearchEventOrUserDataSource.clear() @@ -1140,129 +1221,19 @@ open class NewPostViewModel : ViewModel() { !category.text.isNullOrBlank() ) ) && - contentToAddUrl == null - - suspend fun createNIP94Record( - uploadingResult: Nip96Uploader.PartialEvent, - localContentType: String?, - alt: String?, - sensitiveContent: Boolean, - forceProxy: (String) -> Boolean, - onError: (message: String) -> Unit, - context: Context, - ) { - // Images don't seem to be ready immediately after upload - val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - val remoteMimeType = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "m" } - ?.get(1) - ?.ifBlank { null } - val originalHash = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "ox" } - ?.get(1) - ?.ifBlank { null } - val dim = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "dim" } - ?.get(1) - ?.ifBlank { null } - val magnet = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "magnet" } - ?.get(1) - ?.ifBlank { null } - - if (imageUrl.isNullOrBlank()) { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - isUploadingImage = false - onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) - return - } - - FileHeader.prepare( - fileUrl = imageUrl, - mimeType = remoteMimeType ?: localContentType, - dimPrecomputed = dim, - forceProxy = forceProxy(imageUrl), - onReady = { header: FileHeader -> - account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> - isUploadingImage = false - nip94attachments = nip94attachments.filter { it.url() != event.url() } + event - - message = message.insertUrlAtCursor(imageUrl) - urlPreview = findUrlInMessage() - saveDraft() - } - }, - onError = { - isUploadingImage = false - onError(stringRes(context, R.string.could_not_prepare_header, it)) - }, - ) - } + multiOrchestrator == null fun insertAtCursor(newElement: String) { message = message.insertUrlAtCursor(newElement) } - fun createNIP95Record( - bytes: ByteArray, - mimeType: String?, - alt: String?, - sensitiveContent: Boolean, - onError: (message: String) -> Unit, - context: Context, - ) { - if (bytes.size > 80000) { - viewModelScope.launch { - onError(stringRes(context, id = R.string.media_too_big_for_nip95)) - isUploadingImage = false - } - return - } - - viewModelScope.launch(Dispatchers.IO) { - FileHeader.prepare( - bytes, - mimeType, - null, - onReady = { - account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - - isUploadingImage = false - - note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - } - - urlPreview = findUrlInMessage() - saveDraft() - } - }, - onError = { - isUploadingImage = false - onError(stringRes(context, R.string.could_not_prepare_header, it)) - }, - ) - } + fun selectImage(uris: ImmutableList) { + multiOrchestrator = MultiOrchestrator(uris) } - fun selectImage(uri: Uri) { - contentToAddUrl = uri - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun locationFlow(): Flow { + fun locationFlow(): StateFlow { if (location == null) { - location = - Amethyst.instance.locationManager.locationStateFlow - .mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + location = Amethyst.instance.locationManager.geohashStateFlow } return location!! diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt index 5e1ace95f..0772cf52f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton @@ -151,7 +152,7 @@ fun NewUserMetadataView( ) }, leadingIcon = { - UploadFromGallery( + SelectSingleFromGallery( isUploading = postViewModel.isUploadingImageForPicture, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(start = 5.dp), @@ -176,7 +177,7 @@ fun NewUserMetadataView( ) }, leadingIcon = { - UploadFromGallery( + SelectSingleFromGallery( isUploading = postViewModel.isUploadingImageForBanner, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(start = 5.dp), @@ -189,6 +190,22 @@ fun NewUserMetadataView( Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + label = { Text(text = stringRes(R.string.pronouns)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.pronouns.value, + onValueChange = { postViewModel.pronouns.value = it }, + placeholder = { + Text( + text = "they/them, ...", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( label = { Text(text = stringRes(R.string.website_url)) }, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 21cdfc6ff..3f38cd28d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.Context -import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -29,9 +28,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.Nip96Uploader -import com.vitorpamplona.amethyst.ui.components.CompressorQuality -import com.vitorpamplona.amethyst.ui.components.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.events.GitHubIdentity import com.vitorpamplona.quartz.events.MastodonIdentity @@ -51,6 +53,7 @@ class NewUserMetadataViewModel : ViewModel() { val banner = mutableStateOf("") val website = mutableStateOf("") + val pronouns = mutableStateOf("") val nip05 = mutableStateOf("") val lnAddress = mutableStateOf("") val lnURL = mutableStateOf("") @@ -72,6 +75,7 @@ class NewUserMetadataViewModel : ViewModel() { picture.value = it.info?.picture ?: "" banner.value = it.info?.banner ?: "" website.value = it.info?.website ?: "" + pronouns.value = it.info?.pronouns ?: "" nip05.value = it.info?.nip05 ?: "" lnAddress.value = it.info?.lud16 ?: "" lnURL.value = it.info?.lud06 ?: "" @@ -99,6 +103,7 @@ class NewUserMetadataViewModel : ViewModel() { picture = picture.value, banner = banner.value, website = website.value, + pronouns = pronouns.value, about = about.value, nip05 = nip05.value, lnAddress = lnAddress.value, @@ -127,7 +132,7 @@ class NewUserMetadataViewModel : ViewModel() { } fun uploadForPicture( - uri: Uri, + uri: SelectedMedia, context: Context, onError: (String, String) -> Unit, ) { @@ -143,7 +148,7 @@ class NewUserMetadataViewModel : ViewModel() { } fun uploadForBanner( - uri: Uri, + uri: SelectedMedia, context: Context, onError: (String, String) -> Unit, ) { @@ -159,7 +164,7 @@ class NewUserMetadataViewModel : ViewModel() { } private suspend fun upload( - galleryUri: Uri, + galleryUri: SelectedMedia, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit, @@ -167,54 +172,48 @@ class NewUserMetadataViewModel : ViewModel() { ) { onUploading(true) - val contentResolver = context.contentResolver + val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) - MediaCompressor() - .compress( - galleryUri, - contentResolver.getType(galleryUri), - context.applicationContext, - onReady = { fileUri, contentType, size -> - viewModelScope.launch(Dispatchers.IO) { - try { - val result = - Nip96Uploader(account) - .uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = null, - sensitiveContent = null, - server = account.settings.defaultFileServer, - contentResolver = contentResolver, - forceProxy = account::shouldUseTorForNIP96, - onProgress = {}, - context = context, - ) + try { + val result = + if (account.settings.defaultFileServer.type == ServerType.NIP96) { + Nip96Uploader().uploadImage( + uri = compResult.uri, + contentType = compResult.contentType, + size = compResult.size, + alt = null, + sensitiveContent = null, + serverBaseUrl = account.settings.defaultFileServer.baseUrl, + forceProxy = account::shouldUseTorForNIP96, + onProgress = {}, + httpAuth = account::createHTTPAuthorization, + context = context, + ) + } else { + BlossomUploader().uploadImage( + uri = compResult.uri, + contentType = compResult.contentType, + size = compResult.size, + alt = null, + sensitiveContent = null, + serverBaseUrl = account.settings.defaultFileServer.baseUrl, + forceProxy = account::shouldUseTorForNIP96, + httpAuth = account::createBlossomUploadAuth, + context = context, + ) + } - val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - - if (url != null) { - onUploading(false) - onUploaded(url) - } else { - onUploading(false) - onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - onUploading(false) - onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) - } - } - }, - onError = { - onUploading(false) - - onError(stringRes(context, R.string.error_when_compressing_media), stringRes(context, it)) - }, - // Use MEDIUM quality as default - mediaQuality = CompressorQuality.MEDIUM, - ) + if (result.url != null) { + onUploading(false) + onUploaded(result.url) + } else { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt deleted file mode 100644 index ff5560063..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions - -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Environment -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.compose.animation.core.LinearEasing -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.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddPhotoAlternate -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.core.content.FileProvider -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract -import com.vitorpamplona.amethyst.ui.stringRes -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.concurrent.atomic.AtomicBoolean - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -fun UploadFromGallery( - isUploading: Boolean, - tint: Color, - modifier: Modifier, - onImageChosen: (Uri) -> Unit, -) { - val cameraPermissionState = - rememberPermissionState( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - android.Manifest.permission.READ_MEDIA_IMAGES - } else { - android.Manifest.permission.READ_EXTERNAL_STORAGE - }, - ) - - if (cameraPermissionState.status.isGranted) { - var showGallerySelect by remember { mutableStateOf(false) } - if (showGallerySelect) { - GallerySelect( - onImageUri = { uri -> - showGallerySelect = false - if (uri != null) { - onImageChosen(uri) - } - }, - ) - } - - UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true } - } else { - UploadBoxButton(isUploading, tint, modifier) { cameraPermissionState.launchPermissionRequest() } - } -} - -@Composable -private fun UploadBoxButton( - isUploading: Boolean, - tint: Color, - modifier: Modifier, - onClick: () -> Unit, -) { - Box { - IconButton( - modifier = modifier.align(Alignment.Center), - enabled = !isUploading, - onClick = { onClick() }, - ) { - if (!isUploading) { - Icon( - imageVector = Icons.Default.AddPhotoAlternate, - contentDescription = stringRes(id = R.string.upload_image), - modifier = Modifier.height(25.dp), - tint = tint, - ) - } else { - LoadingAnimation() - } - } - } -} - -fun getPhotoUri(context: Context): Uri { - val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) - return File - .createTempFile( - "JPEG_${timeStamp}_", - ".jpg", - storageDir, - ).let { - FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - it, - ) - } -} - -val DefaultAnimationColors = - listOf( - Color(0xFF5851D8), - Color(0xFF833AB4), - Color(0xFFC13584), - Color(0xFFE1306C), - Color(0xFFFD1D1D), - Color(0xFFF56040), - Color(0xFFF77737), - Color(0xFFFCAF45), - Color(0xFFFFDC80), - Color(0xFF5851D8), - ).toImmutableList() - -@Composable -fun LoadingAnimation( - indicatorSize: Dp = 20.dp, - circleColors: ImmutableList = DefaultAnimationColors, - animationDuration: Int = 1000, -) { - val infiniteTransition = rememberInfiniteTransition() - - val rotateAnimation by - infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = - infiniteRepeatable( - animation = - tween( - durationMillis = animationDuration, - easing = LinearEasing, - ), - ), - ) - - CircularProgressIndicator( - modifier = - Modifier - .size(size = indicatorSize) - .rotate(degrees = rotateAnimation) - .border( - width = 4.dp, - brush = Brush.sweepGradient(circleColors), - shape = CircleShape, - ), - progress = 1f, - strokeWidth = 1.dp, - color = MaterialTheme.colorScheme.background, - ) -} - -@Composable -fun GallerySelect(onImageUri: (Uri?) -> Unit = {}) { - var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) } - val launcher = - rememberLauncherForActivityResult( - contract = GetMediaActivityResultContract(), - onResult = { uri: Uri? -> - onImageUri(uri) - hasLaunched.set(false) - }, - ) - - @Composable - fun LaunchGallery() { - SideEffect { - if (!hasLaunched.getAndSet(true)) { - launcher.launch("*/*") - } - } - } - - LaunchGallery() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt new file mode 100644 index 000000000..8e42148ee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -0,0 +1,365 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.mediaServers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategory +import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton +import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +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.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText + +@Composable +fun MediaServersListView( + onClose: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val nip96ServersViewModel: NIP96ServersViewModel = viewModel() + val blossomServersViewModel: BlossomServersViewModel = viewModel() + + LaunchedEffect(key1 = Unit) { + nip96ServersViewModel.load(accountViewModel.account) + blossomServersViewModel.load(accountViewModel.account) + } + + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + SetDialogToEdgeToEdge() + DialogContent(nip96ServersViewModel, blossomServersViewModel, onClose) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DialogContent( + nip96ServersViewModel: NIP96ServersViewModel, + blossomServersViewModel: BlossomServersViewModel, + onClose: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(id = R.string.media_servers), + style = MaterialTheme.typography.titleLarge, + ) + } + }, + navigationIcon = { + CloseButton( + onPress = { + nip96ServersViewModel.refresh() + blossomServersViewModel.refresh() + onClose() + }, + ) + }, + actions = { + SaveButton( + onPost = { + nip96ServersViewModel.saveFileServers() + blossomServersViewModel.saveFileServers() + onClose() + }, + isActive = true, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = padding.calculateTopPadding(), + end = 16.dp, + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringRes(id = R.string.set_preferred_media_servers), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.grayText, + ) + + AllMediaBody(nip96ServersViewModel, blossomServersViewModel) + } + } +} + +@Composable +fun AllMediaBody( + nip96ServersViewModel: NIP96ServersViewModel, + blossomServersViewModel: BlossomServersViewModel, +) { + val nip96ServersState by nip96ServersViewModel.fileServers.collectAsStateWithLifecycle() + val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle() + + LazyColumn( + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = FeedPadding, + ) { + item { + SettingsCategory( + stringRes(R.string.media_servers_nip96_section), + stringRes(R.string.media_servers_nip96_explainer), + Modifier.padding(bottom = 8.dp), + ) + } + + renderMediaServerList( + mediaServersState = nip96ServersState, + keyType = "nip96", + editLabel = R.string.add_a_nip96_server, + emptyLabel = R.string.no_nip96_server_message, + onAddServer = { server -> + nip96ServersViewModel.addServer(server) + }, + onDeleteServer = { + nip96ServersViewModel.removeServer(serverUrl = it) + }, + ) + + item { + SettingsCategory( + stringRes(R.string.media_servers_blossom_section), + stringRes(R.string.media_servers_blossom_explainer), + ) + } + + renderMediaServerList( + mediaServersState = blossomServersState, + keyType = "blossom", + editLabel = R.string.add_a_blossom_server, + emptyLabel = R.string.no_blossom_server_message, + onAddServer = { server -> + blossomServersViewModel.addServer(server) + }, + onDeleteServer = { + blossomServersViewModel.removeServer(serverUrl = it) + }, + ) + + DEFAULT_MEDIA_SERVERS.let { + item { + SettingsCategoryWithButton( + title = stringRes(id = R.string.built_in_media_servers_title), + description = stringRes(id = R.string.built_in_servers_description), + action = { + OutlinedButton( + onClick = { + nip96ServersViewModel.addServerList( + it.mapNotNull { s -> if (s.type == ServerType.NIP96) s.baseUrl else null }, + ) + + blossomServersViewModel.addServerList( + it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, + ) + }, + ) { + Text(text = stringRes(id = R.string.use_default_servers)) + } + }, + ) + } + itemsIndexed( + it, + key = { index: Int, server: ServerName -> + "Proposed" + server.baseUrl + }, + ) { index, server -> + MediaServerEntry( + serverEntry = server, + isAmethystDefault = true, + onAddOrDelete = { serverUrl -> + if (server.type == ServerType.NIP96) { + nip96ServersViewModel.addServer(serverUrl) + } else if (server.type == ServerType.Blossom) { + blossomServersViewModel.addServer(serverUrl) + } + }, + ) + } + } + + item { + Spacer(DoubleHorzSpacer) + } + } +} + +fun LazyListScope.renderMediaServerList( + mediaServersState: List, + keyType: String, + editLabel: Int, + emptyLabel: Int, + onAddServer: (String) -> Unit, + onDeleteServer: (String) -> Unit, +) { + if (mediaServersState.isEmpty()) { + item { + Text( + text = stringRes(id = emptyLabel), + modifier = DoubleVertPadding, + ) + } + } else { + itemsIndexed( + mediaServersState, + key = { index: Int, server: ServerName -> + keyType + server.baseUrl + }, + ) { index, entry -> + MediaServerEntry( + serverEntry = entry, + onAddOrDelete = { + onDeleteServer(it) + }, + ) + } + } + + item { + Spacer(modifier = StdVertSpacer) + MediaServerEditField(editLabel) { + onAddServer(it) + } + } +} + +@Composable +fun MediaServerEntry( + modifier: Modifier = Modifier, + serverEntry: ServerName, + isAmethystDefault: Boolean = false, + onAddOrDelete: (serverUrl: String) -> Unit, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround, + ) { + Column( + modifier = + Modifier + .weight(1f), + ) { + serverEntry.let { + Text( + text = it.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = StdVertSpacer) + Text( + text = it.baseUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + } + + Row( + horizontalArrangement = Arrangement.End, + ) { + IconButton( + onClick = { + onAddOrDelete(serverEntry.baseUrl) + }, + ) { + Icon( + imageVector = if (isAmethystDefault) Icons.Rounded.Add else Icons.Rounded.Delete, + contentDescription = + if (isAmethystDefault) { + stringRes(id = R.string.add_media_server) + } else { + stringRes(id = R.string.delete_media_server) + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt new file mode 100644 index 000000000..4fd5e81f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.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.actions.mediaServers + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.czeal.rfc3986.URIReference + +class BlossomServersViewModel : ViewModel() { + lateinit var account: Account + + private val _fileServers = MutableStateFlow>(emptyList()) + val fileServers = _fileServers.asStateFlow() + private var isModified = false + + fun load(account: Account) { + this.account = account + refresh() + } + + fun refresh() { + isModified = false + _fileServers.update { + val obtainedFileServers = obtainFileServers() ?: emptyList() + obtainedFileServers.mapNotNull { serverUrl -> + try { + ServerName( + URIReference.parse(serverUrl).host.value, + serverUrl, + ServerType.Blossom, + ) + } catch (e: Exception) { + Log.d("MediaServersViewModel", "Invalid URL in Blossom server list") + null + } + } + } + } + + fun addServerList(serverList: List) { + serverList.forEach { serverUrl -> + addServer(serverUrl) + } + } + + fun addServer(serverUrl: String) { + val normalizedUrl = + try { + URIReference.parse(serverUrl.trim()).normalize().toString() + } catch (e: Exception) { + serverUrl + } + val serverNameReference = + try { + URIReference.parse(normalizedUrl).host.value + } catch (e: Exception) { + normalizedUrl + } + val serverRef = + ServerName( + serverNameReference, + normalizedUrl, + ServerType.Blossom, + ) + if (_fileServers.value.contains(serverRef)) { + return + } else { + _fileServers.update { + it.plus(serverRef) + } + } + isModified = true + } + + fun removeServer( + name: String = "", + serverUrl: String, + ) { + viewModelScope.launch { + val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value + _fileServers.update { + it.minus( + ServerName(serverName, serverUrl, ServerType.Blossom), + ) + } + isModified = true + } + } + + fun removeAllServers() { + _fileServers.update { emptyList() } + isModified = true + } + + fun saveFileServers() { + if (isModified) { + viewModelScope.launch(Dispatchers.IO) { + val serverList = _fileServers.value.map { it.baseUrl } + account.sendBlossomServersList(serverList) + refresh() + } + } + } + + private fun obtainFileServers(): List? = account.getBlossomServersList()?.servers() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt index ee853f4ac..ddbaf1b87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.encoders.HttpUrlFormatter @Composable fun MediaServerEditField( + label: Int = R.string.add_a_nip96_server, modifier: Modifier = Modifier, onAddServer: (String) -> Unit, ) { @@ -63,7 +64,7 @@ fun MediaServerEditField( ), ) { OutlinedTextField( - label = { Text(text = stringRes(R.string.add_a_nip96_server)) }, + label = { Text(text = stringRes(label)) }, modifier = Modifier.weight(1f), value = url, onValueChange = { url = it }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersLIstView.kt deleted file mode 100644 index da14c5e95..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersLIstView.kt +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions.mediaServers - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.Nip96MediaServers -import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -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.DoubleVertPadding -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.grayText - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MediaServersListView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - val mediaServersViewModel: MediaServersViewModel = viewModel() - val mediaServersState by mediaServersViewModel.fileServers.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = Unit) { - mediaServersViewModel.load(accountViewModel.account) - } - - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(id = R.string.media_servers), - style = MaterialTheme.typography.titleLarge, - ) - } - }, - navigationIcon = { - CloseButton( - onPress = { - mediaServersViewModel.refresh() - onClose() - }, - ) - }, - actions = { - SaveButton( - onPost = { - mediaServersViewModel.saveFileServers() - onClose() - }, - isActive = true, - ) - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding( - start = 16.dp, - top = padding.calculateTopPadding(), - end = 16.dp, - bottom = padding.calculateBottomPadding(), - ), - verticalArrangement = Arrangement.spacedBy(5.dp, alignment = Alignment.Top), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - stringRes(id = R.string.set_preferred_media_servers), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.grayText, - ) - - LazyColumn( - verticalArrangement = Arrangement.SpaceAround, - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = FeedPadding, - ) { - renderMediaServerList( - mediaServersState = mediaServersState, - onAddServer = { server -> - mediaServersViewModel.addServer(server) - }, - onDeleteServer = { - mediaServersViewModel.removeServer(serverUrl = it) - }, - ) - - Nip96MediaServers.DEFAULT.let { - item { - SettingsCategoryWithButton( - title = stringRes(id = R.string.built_in_media_servers_title), - description = stringRes(id = R.string.built_in_servers_description), - action = { - OutlinedButton( - onClick = { - mediaServersViewModel.addServerList(it.map { s -> s.baseUrl }) - }, - ) { - Text(text = stringRes(id = R.string.use_default_servers)) - } - }, - ) - } - itemsIndexed( - it, - key = { index: Int, server: Nip96MediaServers.ServerName -> - server.baseUrl - }, - ) { index, server -> - MediaServerEntry( - serverEntry = server, - isAmethystDefault = true, - onAddOrDelete = { serverUrl -> - mediaServersViewModel.addServer(serverUrl) - }, - ) - } - } - } - } - } - } -} - -fun LazyListScope.renderMediaServerList( - mediaServersState: List, - onAddServer: (String) -> Unit, - onDeleteServer: (String) -> Unit, -) { - if (mediaServersState.isEmpty()) { - item { - Text( - text = stringRes(id = R.string.no_media_server_message), - modifier = DoubleVertPadding, - ) - } - } else { - itemsIndexed( - mediaServersState, - key = { index: Int, server: Nip96MediaServers.ServerName -> - server.baseUrl - }, - ) { index, entry -> - MediaServerEntry( - serverEntry = entry, - onAddOrDelete = { - onDeleteServer(it) - }, - ) - } - } - - item { - Spacer(modifier = StdVertSpacer) - MediaServerEditField { - onAddServer(it) - } - } -} - -@Composable -fun MediaServerEntry( - modifier: Modifier = Modifier, - serverEntry: Nip96MediaServers.ServerName, - isAmethystDefault: Boolean = false, - onAddOrDelete: (serverUrl: String) -> Unit, -) { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceAround, - ) { - Column( - modifier = - Modifier - .weight(1f), - ) { - serverEntry.let { - Text( - text = it.name.replaceFirstChar(Char::titlecase), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = StdVertSpacer) - Text( - text = it.baseUrl, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - ) - } - } - - Row( - horizontalArrangement = Arrangement.End, - ) { - IconButton( - onClick = { - onAddOrDelete(serverEntry.baseUrl) - }, - ) { - Icon( - imageVector = if (isAmethystDefault) Icons.Rounded.Add else Icons.Rounded.Delete, - contentDescription = - if (isAmethystDefault) { - stringRes(id = R.string.add_media_server) - } else { - stringRes(id = R.string.delete_media_server) - }, - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt index 90bcbbb4b..3ef4aaf5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt @@ -24,7 +24,6 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.Nip96MediaServers import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -32,10 +31,10 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.czeal.rfc3986.URIReference -class MediaServersViewModel : ViewModel() { +class NIP96ServersViewModel : ViewModel() { lateinit var account: Account - private val _fileServers = MutableStateFlow>(emptyList()) + private val _fileServers = MutableStateFlow>(emptyList()) val fileServers = _fileServers.asStateFlow() private var isModified = false @@ -50,11 +49,11 @@ class MediaServersViewModel : ViewModel() { val obtainedFileServers = obtainFileServers() ?: emptyList() obtainedFileServers.mapNotNull { serverUrl -> try { - Nip96MediaServers - .ServerName( - URIReference.parse(serverUrl).host.value, - serverUrl, - ) + ServerName( + URIReference.parse(serverUrl).host.value, + serverUrl, + ServerType.NIP96, + ) } catch (e: Exception) { Log.d("MediaServersViewModel", "Invalid URL in NIP-96 server list") null @@ -82,7 +81,7 @@ class MediaServersViewModel : ViewModel() { } catch (e: Exception) { normalizedUrl } - val serverRef = Nip96MediaServers.ServerName(serverNameReference, normalizedUrl) + val serverRef = ServerName(serverNameReference, normalizedUrl, ServerType.NIP96) if (_fileServers.value.contains(serverRef)) { return } else { @@ -101,7 +100,7 @@ class MediaServersViewModel : ViewModel() { val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value _fileServers.update { it.minus( - Nip96MediaServers.ServerName(serverName, serverUrl), + ServerName(serverName, serverUrl, ServerType.NIP96), ) } isModified = true 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 new file mode 100644 index 000000000..9574f0c5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.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.actions.mediaServers + +data class ServerName( + val name: String, + val baseUrl: String, + val type: ServerType = ServerType.NIP96, +) + +enum class ServerType { + NIP96, + NIP95, + Blossom, +} + +val DEFAULT_MEDIA_SERVERS: List = + listOf( + ServerName("Nostr.Build", "https://nostr.build", ServerType.NIP96), + ServerName("NostrCheck.me (NIP-96)", "https://nostrcheck.me", ServerType.NIP96), + ServerName("Sovbit", "https://files.sovbit.host", ServerType.NIP96), + ServerName("Void.cat", "https://void.cat", ServerType.NIP96), + 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), + ) 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 c3dce2b17..36967c258 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 @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api @@ -39,21 +40,17 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton @@ -68,18 +65,16 @@ import com.vitorpamplona.ammolite.relays.RelayStat @Composable fun AllRelayListView( - onClose: () -> Unit, - relayToAdd: String = "", + relayToAdd: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { - MappedAllRelayListView(onClose, relayToAdd, accountViewModel, rememberExtendedNav(nav, onClose)) + MappedAllRelayListView(relayToAdd ?: "", accountViewModel, nav) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MappedAllRelayListView( - onClose: () -> Unit, relayToAdd: String = "", accountViewModel: AccountViewModel, newNav: INav, @@ -113,154 +108,150 @@ fun MappedAllRelayListView( privateOutboxViewModel.load(accountViewModel.account) } - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = MinHorzSpacer) + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = MinHorzSpacer) - Text( - text = stringRes(R.string.relay_settings), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) + Text( + text = stringRes(R.string.relay_settings), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) - SaveButton( - onPost = { - kind3ViewModel.create() - dmViewModel.create() - nip65ViewModel.create() - searchViewModel.create() - localViewModel.create() - privateOutboxViewModel.create() - onClose() - }, - true, - ) - } - }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - kind3ViewModel.clear() - dmViewModel.clear() - nip65ViewModel.clear() - searchViewModel.clear() - localViewModel.clear() - privateOutboxViewModel.clear() - onClose() - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - ) { pad -> - LazyColumn( - contentPadding = FeedPadding, - modifier = - Modifier - .fillMaxSize() - .padding( - start = 10.dp, - end = 10.dp, - top = pad.calculateTopPadding(), - bottom = pad.calculateBottomPadding(), - ).consumeWindowInsets(pad), - ) { - item { - SettingsCategory( - stringRes(R.string.public_home_section), - stringRes(R.string.public_home_section_explainer), - Modifier.padding(bottom = 8.dp), - ) - } - renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav) - - item { - SettingsCategory( - stringRes(R.string.public_notif_section), - stringRes(R.string.public_notif_section_explainer), - ) - } - renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav) - - item { - SettingsCategoryWithButton( - stringRes(R.string.private_inbox_section), - stringRes(R.string.private_inbox_section_explainer), - action = { - ResetDMRelays(dmViewModel) - }, - ) - } - renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav) - - item { - SettingsCategory( - stringRes(R.string.private_outbox_section), - stringRes(R.string.private_outbox_section_explainer), - ) - } - renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav) - - item { - SettingsCategoryWithButton( - stringRes(R.string.search_section), - stringRes(R.string.search_section_explainer), - action = { - ResetSearchRelays(searchViewModel) - }, - ) - } - renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav) - - item { - SettingsCategory( - stringRes(R.string.local_section), - stringRes(R.string.local_section_explainer), - ) - } - renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav) - - item { - SettingsCategoryWithButton( - stringRes(R.string.kind_3_section), - stringRes(R.string.kind_3_section_description), - action = { - ResetKind3Relays(kind3ViewModel) - }, - ) - } - renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd) - - if (kind3Proposals.isNotEmpty()) { - item { - SettingsCategory( - stringRes(R.string.kind_3_recommended_section), - stringRes(R.string.kind_3_recommended_section_description), + SaveButton( + onPost = { + kind3ViewModel.create() + dmViewModel.create() + nip65ViewModel.create() + searchViewModel.create() + localViewModel.create() + privateOutboxViewModel.create() + newNav.popBack() + }, + true, ) } - renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav) + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + kind3ViewModel.clear() + dmViewModel.clear() + nip65ViewModel.clear() + searchViewModel.clear() + localViewModel.clear() + privateOutboxViewModel.clear() + newNav.popBack() + }, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + LazyColumn( + contentPadding = FeedPadding, + modifier = + Modifier + .fillMaxSize() + .padding( + start = 10.dp, + end = 10.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding(), + ) { + item { + SettingsCategory( + stringRes(R.string.public_home_section), + stringRes(R.string.public_home_section_explainer), + Modifier.padding(bottom = 8.dp), + ) + } + renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav) + + item { + SettingsCategory( + stringRes(R.string.public_notif_section), + stringRes(R.string.public_notif_section_explainer), + ) + } + renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav) + + item { + SettingsCategoryWithButton( + stringRes(R.string.private_inbox_section), + stringRes(R.string.private_inbox_section_explainer), + action = { + ResetDMRelays(dmViewModel) + }, + ) + } + renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav) + + item { + SettingsCategory( + stringRes(R.string.private_outbox_section), + stringRes(R.string.private_outbox_section_explainer), + ) + } + renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav) + + item { + SettingsCategoryWithButton( + stringRes(R.string.search_section), + stringRes(R.string.search_section_explainer), + action = { + ResetSearchRelays(searchViewModel) + }, + ) + } + renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav) + + item { + SettingsCategory( + stringRes(R.string.local_section), + stringRes(R.string.local_section_explainer), + ) + } + renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav) + + item { + SettingsCategoryWithButton( + stringRes(R.string.kind_3_section), + stringRes(R.string.kind_3_section_description), + action = { + ResetKind3Relays(kind3ViewModel) + }, + ) + } + renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd) + + if (kind3Proposals.isNotEmpty()) { + item { + SettingsCategory( + stringRes(R.string.kind_3_recommended_section), + stringRes(R.string.kind_3_recommended_section_description), + ) } + renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt new file mode 100644 index 000000000..021dad965 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -0,0 +1,193 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.util.concurrent.atomic.AtomicBoolean + +@Stable +class SelectedMedia( + val uri: Uri, + val mimeType: String?, +) { + fun isImage() = mimeType?.startsWith("image") + + fun isVideo() = mimeType?.startsWith("video") +} + +@Composable +fun SelectFromGallery( + isUploading: Boolean, + tint: Color, + modifier: Modifier, + onImageChosen: (ImmutableList) -> Unit, +) { + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelect( + onImageUri = { uri -> + showGallerySelect = false + if (uri.isNotEmpty()) { + onImageChosen(uri) + } + }, + ) + } + + GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true } +} + +@Composable +fun SelectSingleFromGallery( + isUploading: Boolean, + tint: Color, + modifier: Modifier, + onImageChosen: (SelectedMedia) -> Unit, +) { + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelectSingle( + onImageUri = { media -> + showGallerySelect = false + if (media != null) { + onImageChosen(media) + } + }, + ) + } + + GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true } +} + +@Composable +private fun GallerySelectButton( + isUploading: Boolean, + tint: Color, + modifier: Modifier, + onClick: () -> Unit, +) { + Box { + IconButton( + modifier = modifier.align(Alignment.Center), + enabled = !isUploading, + onClick = { onClick() }, + ) { + if (!isUploading) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = stringRes(id = R.string.upload_image), + modifier = Modifier.height(25.dp), + tint = tint, + ) + } else { + LoadingAnimation() + } + } + } +} + +@Composable +fun GallerySelect(onImageUri: (ImmutableList) -> Unit = {}) { + val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) } + val resolver = LocalContext.current.contentResolver + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(10), + onResult = { uris: List -> + onImageUri( + uris + .map { + SelectedMedia(it, resolver.getType(it)) + }.toImmutableList(), + ) + hasLaunched.set(false) + }, + ) + + @Composable + fun LaunchGallery() { + SideEffect { + if (!hasLaunched.getAndSet(true)) { + launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) + } + } + } + + LaunchGallery() +} + +@Composable +fun GallerySelectSingle(onImageUri: (SelectedMedia?) -> Unit = {}) { + val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) } + val resolver = LocalContext.current.contentResolver + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri: Uri? -> + if (uri != null) { + onImageUri(SelectedMedia(uri, resolver.getType(uri))) + } else { + onImageUri(null) + } + + hasLaunched.set(false) + }, + ) + + @Composable + fun LaunchGallery() { + SideEffect { + if (!hasLaunched.getAndSet(true)) { + launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) + } + } + } + + LaunchGallery() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt new file mode 100644 index 000000000..b9b22e1cf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.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.amethyst.ui.actions.uploads + +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator + +class SelectedMediaProcessing( + val media: SelectedMedia, + val orchestrator: UploadOrchestrator = UploadOrchestrator(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt new file mode 100644 index 000000000..66fcc1f95 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt @@ -0,0 +1,248 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid +import com.vitorpamplona.amethyst.ui.components.VideoView +import com.vitorpamplona.amethyst.ui.note.CloseIcon +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.Size40Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun ShowImageUploadGallery( + list: MultiOrchestrator, + onDelete: (SelectedMediaProcessing) -> Unit, + accountViewModel: AccountViewModel, +) { + AutoNonlazyGrid(list.size()) { + ShowImageUploadItem(list.get(it), onDelete, accountViewModel) + } +} + +/** + * Creates a bitmap thumbnail from video uri of the scheme type content:// + */ +fun createVideoThumb( + context: Context, + uri: Uri, +): Bitmap? { + try { + val mediaMetadataRetriever = MediaMetadataRetriever() + mediaMetadataRetriever.setDataSource(context, uri) + return mediaMetadataRetriever.frameAtTime + } catch (ex: Exception) { + Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", ex) + } + return null +} + +@Composable +fun ShowImageUploadItem( + item: SelectedMediaProcessing, + onDelete: (SelectedMediaProcessing) -> Unit, + accountViewModel: AccountViewModel, +) { + if (item.media.isImage() == true) { + AsyncImage( + model = item.media.uri.toString(), + contentDescription = item.media.uri.toString(), + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) + } else if (item.media.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var bitmap by remember { mutableStateOf(null) } + val context = LocalContext.current + + LaunchedEffect(key1 = item) { + launch(Dispatchers.IO) { + try { + bitmap = createVideoThumb(context, item.media.uri) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) + } + } + } + + if (bitmap != null) { + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + VideoView( + videoUri = item.media.uri.toString(), + mimeType = item.media.mimeType, + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } else { + VideoView( + videoUri = item.media.uri.toString(), + mimeType = item.media.mimeType, + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + + OrchestratorOverlay(item.orchestrator) { + onDelete(item) + } +} + +@Composable +fun OrchestratorOverlay( + orchestrator: UploadOrchestrator, + onDelete: () -> Unit, +) { + val progress by orchestrator.progress.collectAsState() + val progressState by orchestrator.progressState.collectAsState() + + if (progressState is UploadingState.Ready) { + DeleteButton(onDelete) + } else { + UploadingState(progress, progressState) + } +} + +@Composable +fun DeleteButton(onDelete: () -> Unit) { + Box( + contentAlignment = Alignment.TopEnd, + modifier = Modifier.fillMaxSize(), + ) { + Box(Size40Modifier, contentAlignment = Alignment.Center) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) + + IconButton( + modifier = Size20Modifier, + onClick = onDelete, + ) { + CloseIcon() + } + } + } +} + +@Composable +fun UploadingState( + progress: Double, + progressState: UploadingState, +) { + Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) { + val animatedProgress by animateFloatAsState( + targetValue = progress.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ) + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressState) { + is UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt new file mode 100644 index 000000000..e482f68fb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import android.Manifest +import android.content.Context +import android.net.Uri +import android.os.Environment +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun TakePictureButton(onPictureTaken: (ImmutableList) -> Unit) { + var showCamera by remember { mutableStateOf(false) } + if (showCamera) { + TakePicture( + onPictureTaken = { uri -> + showCamera = false + if (uri.isNotEmpty()) { + onPictureTaken(uri) + } + }, + ) + } + + PictureButton { showCamera = true } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun TakePicture(onPictureTaken: (ImmutableList) -> Unit) { + val context = LocalContext.current + var cameraUri by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture(), + ) { success -> + if (success) { + cameraUri?.let { + onPictureTaken(persistentListOf(SelectedMedia(it, "image/jpeg"))) + } + } else { + onPictureTaken(persistentListOf()) + } + cameraUri = null + } + + val cameraPermissionState = + rememberPermissionState( + Manifest.permission.CAMERA, + onPermissionResult = { + if (it) { + scope.launch(Dispatchers.IO) { + cameraUri = getPhotoUri(context) + cameraUri?.let { launcher.launch(it) } + } + } + }, + ) + + if (cameraPermissionState.status.isGranted) { + LaunchedEffect(key1 = Unit) { + launch(Dispatchers.IO) { + cameraUri = getPhotoUri(context) + cameraUri?.let { launcher.launch(it) } + } + } + } else { + LaunchedEffect(key1 = Unit) { + cameraPermissionState.launchPermissionRequest() + } + } +} + +@Composable +fun PictureButton(onClick: () -> Unit) { + IconButton( + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = stringRes(id = R.string.upload_image), + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } +} + +fun getPhotoUri(context: Context): Uri { + val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File + .createTempFile( + "JPEG_${timeStamp}_", + ".jpg", + storageDir, + ).let { + FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + it, + ) + } +} 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 5123cbc39..47f87f4bb 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 @@ -62,7 +62,6 @@ import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.service.CachedCashuProcessor import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.CopyIcon import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon import com.vitorpamplona.amethyst.ui.note.ZapIcon diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt index 3f096566c..d99e1ab51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt @@ -37,11 +37,9 @@ fun ClickableNoteTag( accountViewModel: AccountViewModel, nav: INav, ) { - val route = routeFor(baseNote, accountViewModel.userProfile()) - ClickableText( text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"), - onClick = { nav.nav("Note/${baseNote.idHex}") }, + onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } }, style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index f1105390f..4fbd81863 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState +import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.model.UrlCachedPreviewer @@ -81,7 +82,7 @@ fun RenderLoaded( ZoomableContentView( content = MediaUrlImage(url, uri = callbackUri), roundedCorner = true, - isFiniteHeight = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) } @@ -90,7 +91,7 @@ fun RenderLoaded( ZoomableContentView( content = MediaUrlVideo(url, uri = callbackUri), roundedCorner = true, - isFiniteHeight = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt new file mode 100644 index 000000000..c1245cbff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.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.amethyst.ui.components + +import androidx.compose.animation.core.LinearEasing +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.border +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +val DefaultAnimationColors = + listOf( + Color(0xFF5851D8), + Color(0xFF833AB4), + Color(0xFFC13584), + Color(0xFFE1306C), + Color(0xFFFD1D1D), + Color(0xFFF56040), + Color(0xFFF77737), + Color(0xFFFCAF45), + Color(0xFFFFDC80), + Color(0xFF5851D8), + ).toImmutableList() + +@Composable +fun LoadingAnimation( + indicatorSize: Dp = 20.dp, + circleColors: ImmutableList = DefaultAnimationColors, + animationDuration: Int = 1000, +) { + val infiniteTransition = rememberInfiniteTransition() + + val rotateAnimation by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = animationDuration, + easing = LinearEasing, + ), + ), + label = "UploadGalleryUploadingAnimation", + ) + + CircularProgressIndicator( + progress = { 1f }, + modifier = + Modifier + .size(size = indicatorSize) + .rotate(degrees = rotateAnimation) + .border( + width = 4.dp, + brush = Brush.sweepGradient(circleColors), + shape = CircleShape, + ), + color = MaterialTheme.colorScheme.background, + strokeWidth = 1.dp, + trackColor = ProgressIndicatorDefaults.circularDeterminateTrackColor, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt deleted file mode 100644 index 96b6c8ecc..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.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.amethyst.ui.components - -import android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import android.util.Log -import androidx.core.net.toUri -import com.abedelazizshe.lightcompressorlibrary.CompressionListener -import com.abedelazizshe.lightcompressorlibrary.VideoCompressor -import com.abedelazizshe.lightcompressorlibrary.VideoQuality -import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration -import com.abedelazizshe.lightcompressorlibrary.config.Configuration -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils -import id.zelory.compressor.Compressor -import id.zelory.compressor.constraint.default -import kotlinx.coroutines.CancellationException -import java.io.File -import java.util.UUID - -class MediaCompressor { - suspend fun compress( - uri: Uri, - contentType: String?, - applicationContext: Context, - onReady: (Uri, String?, Long?) -> Unit, - onError: (Int) -> Unit, - mediaQuality: CompressorQuality, - ) { - // Skip compression if user selected uncompressed - if (mediaQuality == CompressorQuality.UNCOMPRESSED) { - Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.") - onReady(uri, contentType, null) - return - } - - checkNotInMainThread() - - // branch into compression based on content type - when { - contentType?.startsWith("video", ignoreCase = true) == true -> - compressVideo(uri, contentType, applicationContext, onReady, onError, mediaQuality) - contentType?.startsWith("image", ignoreCase = true) == true && - !contentType.contains("gif") && - !contentType.contains("svg") -> - compressImage(uri, contentType, applicationContext, onReady, onError, mediaQuality) - else -> onReady(uri, contentType, null) - } - } - - private fun compressVideo( - uri: Uri, - contentType: String?, - applicationContext: Context, - onReady: (Uri, String?, Long?) -> Unit, - onError: (Int) -> Unit, - mediaQuality: CompressorQuality, - ) { - val videoQuality = - when (mediaQuality) { - CompressorQuality.VERY_LOW -> VideoQuality.VERY_LOW - CompressorQuality.LOW -> VideoQuality.LOW - CompressorQuality.MEDIUM -> VideoQuality.MEDIUM - CompressorQuality.HIGH -> VideoQuality.HIGH - CompressorQuality.VERY_HIGH -> VideoQuality.VERY_HIGH - else -> VideoQuality.MEDIUM - } - - Log.d("MediaCompressor", "Using video compression $mediaQuality") - - VideoCompressor.start( - // => This is required - context = applicationContext, - // => Source can be provided as content uris - uris = listOf(uri), - isStreamable = false, - // THIS STORAGE - // sharedStorageConfiguration = SharedStorageConfiguration( - // saveAt = SaveLocation.movies, // => default is movies - // videoName = "compressed_video" // => required name - // ), - // OR AND NOT BOTH - appSpecificStorageConfiguration = AppSpecificStorageConfiguration(), - configureWith = - Configuration( - quality = videoQuality, - // => required name - videoNames = listOf(UUID.randomUUID().toString()), - ), - listener = - object : CompressionListener { - override fun onProgress( - index: Int, - percent: Float, - ) { - } - - override fun onStart(index: Int) {} - - override fun onSuccess( - index: Int, - size: Long, - path: String?, - ) { - if (path != null) { - Log.d("MediaCompressor", "Video compression success. Compressed size [$size]") - onReady(Uri.fromFile(File(path)), contentType, size) - } else { - Log.d("MediaCompressor", "Video compression successful, but returned null path") - onError(R.string.compression_returned_null) - } - } - - override fun onFailure( - index: Int, - failureMessage: String, - ) { - Log.d("MediaCompressor", "Video compression failed: $failureMessage") - // keeps going with original video - onReady(uri, contentType, null) - } - - override fun onCancelled(index: Int) { - onError(R.string.compression_cancelled) - } - }, - ) - } - - private suspend fun compressImage( - uri: Uri, - contentType: String?, - context: Context, - onReady: (Uri, String?, Long?) -> Unit, - onError: (Int) -> Unit, - mediaQuality: CompressorQuality, - ) { - val imageQuality = - when (mediaQuality) { - CompressorQuality.VERY_LOW -> 40 - CompressorQuality.LOW -> 50 - CompressorQuality.MEDIUM -> 60 - CompressorQuality.HIGH -> 80 - CompressorQuality.VERY_HIGH -> 90 - else -> 60 - } - - try { - Log.d("MediaCompressor", "Using image compression $mediaQuality") - val tempFile = MediaCompressorFileUtils.from(uri, context) - val compressedImageFile = - Compressor.compress(context, tempFile) { - default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality) - } - Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]") - onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length()) - } catch (e: Exception) { - Log.d("MediaCompressor", "Image compression failed: ${e.message}") - if (e is CancellationException) throw e - e.printStackTrace() - onReady(uri, contentType, null) - } - } - - fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality = - when (mediaQualityFloat) { - 0 -> CompressorQuality.LOW - 1 -> CompressorQuality.MEDIUM - 2 -> CompressorQuality.HIGH - 3 -> CompressorQuality.UNCOMPRESSED - else -> CompressorQuality.MEDIUM - } - - fun compressorQualityToInt(compressorQuality: CompressorQuality): Int = - when (compressorQuality) { - CompressorQuality.LOW -> 0 - CompressorQuality.MEDIUM -> 1 - CompressorQuality.HIGH -> 2 - CompressorQuality.UNCOMPRESSED -> 3 - else -> 1 - } -} - -enum class CompressorQuality { - VERY_LOW, - LOW, - MEDIUM, - HIGH, - VERY_HIGH, - UNCOMPRESSED, -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt new file mode 100644 index 000000000..4613d550f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.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.amethyst.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.google.common.math.IntMath.sqrt +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import java.math.RoundingMode +import kotlin.math.ceil + +@Composable +fun GridPreviewTemplate(count: Int) { + AutoNonlazyGrid(count) { idx -> + Box(Modifier.background(Color.Green).fillMaxSize()) + Text(idx.toString()) + } +} + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items1() = GridPreviewTemplate(1) + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items2() = GridPreviewTemplate(2) + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items3() = GridPreviewTemplate(3) + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items4() = GridPreviewTemplate(4) + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items5() = GridPreviewTemplate(5) + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items6() = GridPreviewTemplate(6) + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items7() = GridPreviewTemplate(7) + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items8() = GridPreviewTemplate(8) + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items9() = GridPreviewTemplate(9) + +@Composable +@Preview(device = "spec:width=300px,height=300px,dpi=440") +fun Items10() = GridPreviewTemplate(10) + +@Composable +fun AutoNonlazyGrid( + itemCount: Int, + modifier: Modifier = Modifier.aspectRatio(1f), + content: @Composable (Int) -> Unit, +) { + if (itemCount > 0) { + NonlazyGrid(sqrt(itemCount, RoundingMode.UP), itemCount, modifier, content) + } +} + +@Composable +fun NonlazyGrid( + columns: Int, + itemCount: Int, + modifier: Modifier = Modifier.aspectRatio(1f), + content: @Composable (Int) -> Unit, +) { + val skipItems = ((columns * columns) - itemCount) + val skipInEveryRow = (skipItems / columns) + val shouldSkip = skipItems % columns + var addSkips = shouldSkip + + val newColumns = columns - skipInEveryRow + + Column(modifier = modifier, verticalArrangement = spacedBy(Size5dp)) { + val rows = ceil(itemCount.toDouble() / newColumns).toInt() + + for (rowId in 0 until rows) { + val firstIndex = if (rowId == 0) 0 else (rowId * newColumns) - (shouldSkip - addSkips) + + Row(Modifier.weight(1f), horizontalArrangement = spacedBy(Size5dp)) { + val thisRowColumns = + if (addSkips > 0) { + addSkips-- + newColumns - 1 + } else { + newColumns + } + + for (columnId in 0 until thisRowColumns) { + val index = firstIndex + columnId + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f), + ) { + if (index < itemCount) { + content(index) + } + } + } + } + } + } +} 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 b79c1cb76..9c89ed4a7 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 @@ -47,6 +47,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.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection @@ -421,7 +422,7 @@ private fun ZoomableContentView( ) { state.imagesForPager[word]?.let { Box(modifier = HalfVertPadding) { - ZoomableContentView(it, state.imageList, roundedCorner = true, isFiniteHeight = false, accountViewModel) + ZoomableContentView(it, state.imageList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt index e3ce26a64..72db84e34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt @@ -56,7 +56,6 @@ import com.vitorpamplona.amethyst.ui.stringRes fun SwipeToDeleteContainer( modifier: Modifier = Modifier, onStartToEnd: () -> Unit, - onEndToStart: () -> Unit, content: @Composable (RowScope.() -> Unit), ) { val dismissState = @@ -66,9 +65,7 @@ fun SwipeToDeleteContainer( StartToEnd -> { onStartToEnd() } - EndToStart -> { - onEndToStart() - } + EndToStart -> return@rememberSwipeToDismissBoxState false Settled -> return@rememberSwipeToDismissBoxState false } return@rememberSwipeToDismissBoxState true @@ -80,11 +77,11 @@ fun SwipeToDeleteContainer( state = dismissState, modifier = modifier, backgroundContent = { DismissBackground(dismissState) }, + enableDismissFromEndToStart = false, content = content, ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DismissBackground(dismissState: SwipeToDismissBoxState) { val color by animateColorAsState( 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 3acea4ae1..77a52c90a 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 @@ -28,8 +28,7 @@ import android.net.Uri import android.os.Build import android.util.Log import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout +import android.widget.Toast import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing @@ -99,6 +98,7 @@ import com.linc.audiowaveform.infiniteLinearGradient import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.produceCachedState +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.service.playback.PlaybackClientController import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon @@ -119,7 +119,7 @@ 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.ammolite.service.HttpClientManager +import com.vitorpamplona.quartz.encoders.Dimension import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -142,7 +142,7 @@ fun LoadThumbAndThenVideoView( thumbUri: String, authorName: String? = null, roundedCorner: Boolean, - isFiniteHeight: Boolean, + contentScale: ContentScale, nostrUriCallback: String? = null, accountViewModel: AccountViewModel, onDialog: ((Boolean) -> Unit)? = null, @@ -174,7 +174,7 @@ fun LoadThumbAndThenVideoView( title = title, thumb = VideoThumb(loadingFinished.second), roundedCorner = roundedCorner, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, artworkUri = thumbUri, authorName = authorName, nostrUriCallback = nostrUriCallback, @@ -188,7 +188,7 @@ fun LoadThumbAndThenVideoView( title = title, thumb = null, roundedCorner = roundedCorner, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, artworkUri = thumbUri, authorName = authorName, nostrUriCallback = nostrUriCallback, @@ -207,11 +207,11 @@ fun VideoView( thumb: VideoThumb? = null, roundedCorner: Boolean, gallery: Boolean = false, - isFiniteHeight: Boolean, + contentScale: ContentScale, waveform: ImmutableList? = null, artworkUri: String? = null, authorName: String? = null, - dimensions: String? = null, + dimensions: Dimension? = null, blurhash: String? = null, nostrUriCallback: String? = null, onDialog: ((Boolean) -> Unit)? = null, @@ -228,7 +228,7 @@ fun VideoView( Modifier } - VideoView(videoUri, mimeType, title, thumb, borderModifier, isFiniteHeight, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo) + VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo) } @Composable @@ -238,17 +238,18 @@ fun VideoView( title: String? = null, thumb: VideoThumb? = null, borderModifier: Modifier, - isFiniteHeight: Boolean, + contentScale: ContentScale, waveform: ImmutableList? = null, artworkUri: String? = null, authorName: String? = null, - dimensions: String? = null, + dimensions: Dimension? = null, blurhash: String? = null, nostrUriCallback: String? = null, onDialog: ((Boolean) -> Unit)? = null, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, accountViewModel: AccountViewModel, alwaysShowVideo: Boolean = false, + showControls: Boolean = true, ) { val defaultToStart by remember(videoUri) { mutableStateOf(DEFAULT_MUTED_SETTING.value) } @@ -260,7 +261,7 @@ fun VideoView( } if (blurhash == null) { - val ratio = aspectRatio(dimensions) + val ratio = dimensions?.aspectRatio() val modifier = if (ratio != null && automaticallyStartPlayback.value) { Modifier.aspectRatio(ratio) @@ -281,7 +282,7 @@ fun VideoView( title = title, thumb = thumb, borderModifier = borderModifier, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, waveform = waveform, artworkUri = artworkUri, authorName = authorName, @@ -294,7 +295,7 @@ fun VideoView( } } } else { - val ratio = aspectRatio(dimensions) + val ratio = dimensions?.aspectRatio() val modifier = if (ratio != null) { @@ -308,7 +309,7 @@ fun VideoView( DisplayBlurHash( blurhash, null, - if (isFiniteHeight) ContentScale.FillWidth else ContentScale.FillWidth, + contentScale, if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier, ) @@ -327,7 +328,7 @@ fun VideoView( title = title, thumb = thumb, borderModifier = borderModifier, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, waveform = waveform, artworkUri = artworkUri, authorName = authorName, @@ -336,6 +337,7 @@ fun VideoView( onControllerVisibilityChanged = onControllerVisibilityChanged, onDialog = onDialog, accountViewModel = accountViewModel, + showControls = showControls, ) } } @@ -351,7 +353,7 @@ fun VideoViewInner( title: String? = null, thumb: VideoThumb? = null, showControls: Boolean = true, - isFiniteHeight: Boolean, + contentScale: ContentScale, borderModifier: Modifier, waveform: ImmutableList? = null, artworkUri: String? = null, @@ -362,22 +364,22 @@ fun VideoViewInner( onDialog: ((Boolean) -> Unit)? = null, accountViewModel: AccountViewModel, ) { - VideoPlayerActiveMutex(videoUri) { videoModifier, activeOnScreen -> - GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem -> - GetVideoController( - mediaItem = mediaItem, - videoUri = videoUri, - defaultToStart = defaultToStart, - nostrUriCallback = nostrUriCallback, - proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(videoUri)), - ) { controller, keepPlaying -> + GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + videoUri = videoUri, + defaultToStart = defaultToStart, + nostrUriCallback = nostrUriCallback, + proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(videoUri)), + ) { controller, keepPlaying -> + VideoPlayerActiveMutex(controller) { videoModifier, activeOnScreen -> RenderVideoPlayer( videoUri = videoUri, mimeType = mimeType, controller = controller, thumbData = thumb, showControls = showControls, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, nostrUriCallback = nostrUriCallback, waveform = waveform, keepPlaying = keepPlaying, @@ -669,22 +671,22 @@ class VisibilityData { */ @Composable fun VideoPlayerActiveMutex( - videoUri: String, + controller: MediaController, inner: @Composable (Modifier, MutableState) -> Unit, ) { - val myCache = remember(videoUri) { VisibilityData() } + val myCache = remember(controller) { VisibilityData() } // Is the current video the closest to the center? - val active = remember(videoUri) { mutableStateOf(false) } + val active = remember(controller) { mutableStateOf(false) } // Keep track of all available videos. - DisposableEffect(key1 = videoUri) { + DisposableEffect(key1 = controller) { trackingVideos.add(myCache) onDispose { trackingVideos.remove(myCache) } } val videoModifier = - remember(videoUri) { + remember(controller) { Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { distanceToCenter -> myCache.distanceToCenter = distanceToCenter @@ -728,7 +730,7 @@ private fun RenderVideoPlayer( controller: MediaController, thumbData: VideoThumb?, showControls: Boolean = true, - isFiniteHeight: Boolean, + contentScale: ContentScale, nostrUriCallback: String?, waveform: ImmutableList? = null, keepPlaying: MutableState, @@ -750,24 +752,25 @@ private fun RenderVideoPlayer( factory = { context: Context -> PlayerView(context).apply { player = controller - layoutParams = - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) setBackgroundColor(Color.Transparent.toArgb()) setShutterBackgroundColor(Color.Transparent.toArgb()) + controllerAutoShow = false useController = showControls thumbData?.thumb?.let { defaultArtwork = it } hideController() + resizeMode = - if (isFiniteHeight) { - AspectRatioFrameLayout.RESIZE_MODE_FIT - } else { - AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + when (contentScale) { + ContentScale.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + ContentScale.FillWidth -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_FILL + ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT + ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + else -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH } + if (showControls) { onDialog?.let { innerOnDialog -> setFullscreenButtonClickListener { @@ -1187,9 +1190,17 @@ fun SaveButton(onSaveClick: (localContext: Context) -> Unit) { onSaveClick(localContext) } } - + val scope = rememberCoroutineScope() IconButton( onClick = { + scope.launch { + Toast + .makeText( + localContext, + stringRes(localContext, R.string.video_download_has_started_toast), + Toast.LENGTH_SHORT, + ).show() + } if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted 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 4556c0f1e..17d98c244 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 @@ -23,13 +23,11 @@ package com.vitorpamplona.amethyst.ui.components import android.Manifest import android.content.Context import android.os.Build -import android.view.View import android.view.WindowManager -import android.widget.FrameLayout +import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box @@ -55,19 +53,17 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.SideEffect 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.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri -import androidx.core.view.ViewCompat import coil3.compose.AsyncImage import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted @@ -104,8 +100,6 @@ fun ZoomableImageDialog( onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { - val orientation = LocalConfiguration.current.orientation - Dialog( onDismissRequest = onDismiss, properties = @@ -114,36 +108,21 @@ fun ZoomableImageDialog( decorFitsSystemWindows = false, ), ) { - val view = LocalView.current - val insets = ViewCompat.getRootWindowInsets(view) - val orientation = LocalConfiguration.current.orientation println("This Log only exists to force orientation listener $orientation") val activityWindow = getActivityWindow() val dialogWindow = getDialogWindow() - val parentView = LocalView.current.parent as View - SideEffect { - if (activityWindow != null && dialogWindow != null) { - val attributes = WindowManager.LayoutParams() - attributes.copyFrom(activityWindow.attributes) - attributes.type = dialogWindow.attributes.type - dialogWindow.attributes = attributes - parentView.layoutParams = - FrameLayout.LayoutParams( - activityWindow.decorView.width, - activityWindow.decorView.height, - ) - view.layoutParams = - FrameLayout.LayoutParams( - activityWindow.decorView.width, - activityWindow.decorView.height, - ) - } + + if (activityWindow != null && dialogWindow != null) { + val attributes = WindowManager.LayoutParams() + attributes.copyFrom(activityWindow.attributes) + attributes.type = dialogWindow.attributes.type + dialogWindow.attributes = attributes } - Surface(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Surface(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize(), Alignment.TopCenter) { DialogContent(allImages, imageUrl, onDismiss, accountViewModel) } } @@ -151,7 +130,7 @@ fun ZoomableImageDialog( } @Composable -@OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class) +@OptIn(ExperimentalPermissionsApi::class) private fun DialogContent( allImages: ImmutableList, imageUrl: BaseMediaContent, @@ -258,10 +237,20 @@ private fun DialogContent( if (myContent !is MediaUrlContent || !myContent.url.endsWith(".m3u8")) { val localContext = LocalContext.current + val scope = rememberCoroutineScope() + val writeStoragePermissionState = rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> if (isGranted) { saveMediaToGallery(myContent, localContext, accountViewModel) + scope.launch { + Toast + .makeText( + localContext, + stringRes(localContext, R.string.media_download_has_started_toast), + Toast.LENGTH_SHORT, + ).show() + } } } @@ -272,6 +261,14 @@ private fun DialogContent( writeStoragePermissionState.status.isGranted ) { saveMediaToGallery(myContent, localContext, accountViewModel) + scope.launch { + Toast + .makeText( + localContext, + stringRes(localContext, R.string.media_download_has_started_toast), + Toast.LENGTH_SHORT, + ).show() + } } else { writeStoragePermissionState.launchPermissionRequest() } @@ -296,7 +293,7 @@ private fun saveMediaToGallery( localContext: Context, accountViewModel: AccountViewModel, ) { - val isImage = content is MediaUrlImage && content is MediaLocalImage + val isImage = content is MediaUrlImage || content is MediaLocalImage val success = if (isImage) R.string.image_saved_to_the_gallery else R.string.video_saved_to_the_gallery val failure = if (isImage) R.string.failed_to_save_the_image else R.string.failed_to_save_the_video @@ -329,8 +326,8 @@ private fun saveMediaToGallery( onSuccess = { accountViewModel.toast(success, success) }, - onError = { - accountViewModel.toast(failure, null, it) + onError = { innerIt -> + accountViewModel.toast(failure, null, innerIt) }, ) } @@ -338,7 +335,6 @@ private fun saveMediaToGallery( } @Composable -@OptIn(ExperimentalFoundationApi::class) fun InlineCarrousel( allImages: ImmutableList, imageUrl: String, @@ -385,7 +381,7 @@ private fun RenderImageOrVideo( onToggleControllerVisibility: (() -> Unit)? = null, accountViewModel: AccountViewModel, ) { - val automaticallyStartPlayback = remember { mutableStateOf(true) } + val automaticallyStartPlayback = remember { mutableStateOf(true) } val contentScale = if (isFiniteHeight) { ContentScale.Fit @@ -394,92 +390,100 @@ private fun RenderImageOrVideo( } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - if (content is MediaUrlImage) { - val mainModifier = - Modifier - .fillMaxWidth() - .zoomable( - rememberZoomState(), - onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() - } - }, - ) + when (content) { + is MediaUrlImage -> { + val mainModifier = + Modifier + .fillMaxWidth() + .zoomable( + rememberZoomState(), + onTap = { + if (onToggleControllerVisibility != null) { + onToggleControllerVisibility() + } + }, + ) - UrlImageView( - content = content, - contentScale = contentScale, - mainImageModifier = mainModifier, - loadedImageModifier = Modifier.fillMaxWidth(), - controllerVisible = controllerVisible, - accountViewModel = accountViewModel, - alwayShowImage = true, - ) - } else if (content is MediaUrlVideo) { - val borderModifier = - if (roundedCorner) { - MaterialTheme.colorScheme.imageModifier - } else { - Modifier.fillMaxWidth() - } + UrlImageView( + content = content, + contentScale = contentScale, + mainImageModifier = mainModifier, + loadedImageModifier = Modifier.fillMaxWidth(), + controllerVisible = controllerVisible, + accountViewModel = accountViewModel, + alwayShowImage = true, + ) + } - VideoViewInner( - videoUri = content.url, - mimeType = content.mimeType, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - borderModifier = borderModifier, - isFiniteHeight = isFiniteHeight, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - accountViewModel = accountViewModel, - ) - } else if (content is MediaLocalImage) { - val mainModifier = - Modifier - .fillMaxWidth() - .zoomable( - rememberZoomState(), - onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() - } - }, - ) + is MediaUrlVideo -> { + val borderModifier = + if (roundedCorner) { + MaterialTheme.colorScheme.imageModifier + } else { + Modifier.fillMaxWidth() + } - LocalImageView( - content = content, - contentScale = contentScale, - mainImageModifier = mainModifier, - loadedImageModifier = Modifier.fillMaxWidth(), - controllerVisible = controllerVisible, - accountViewModel = accountViewModel, - alwayShowImage = true, - ) - } else if (content is MediaLocalVideo) { - val borderModifier = - if (roundedCorner) { - MaterialTheme.colorScheme.imageModifier - } else { - Modifier.fillMaxWidth() - } - - content.localFile?.let { VideoViewInner( - videoUri = it.toUri().toString(), + videoUri = content.url, mimeType = content.mimeType, title = content.description, artworkUri = content.artworkUri, authorName = content.authorName, borderModifier = borderModifier, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, automaticallyStartPlayback = automaticallyStartPlayback, onControllerVisibilityChanged = onControllerVisibilityChanged, accountViewModel = accountViewModel, ) } + + is MediaLocalImage -> { + val mainModifier = + Modifier + .fillMaxWidth() + .zoomable( + rememberZoomState(), + onTap = { + if (onToggleControllerVisibility != null) { + onToggleControllerVisibility() + } + }, + ) + + LocalImageView( + content = content, + contentScale = contentScale, + mainImageModifier = mainModifier, + loadedImageModifier = Modifier.fillMaxWidth(), + controllerVisible = controllerVisible, + accountViewModel = accountViewModel, + alwayShowImage = true, + ) + } + + is MediaLocalVideo -> { + val borderModifier = + if (roundedCorner) { + MaterialTheme.colorScheme.imageModifier + } else { + Modifier.fillMaxWidth() + } + + content.localFile?.let { + VideoViewInner( + videoUri = it.toUri().toString(), + mimeType = content.mimeType, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + borderModifier = borderModifier, + contentScale = contentScale, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + accountViewModel = accountViewModel, + ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index e6abe6698..020953990 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 @@ -25,12 +25,14 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.layout.width import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton @@ -54,13 +56,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp import androidx.core.net.toUri -import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage @@ -75,10 +76,9 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo -import com.vitorpamplona.amethyst.service.BlurHashRequester +import com.vitorpamplona.amethyst.service.Blurhash import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog -import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.note.BlankNote @@ -87,23 +87,20 @@ import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon import com.vitorpamplona.amethyst.ui.note.HashCheckIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font17SP import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.Size30dp import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.seconds @@ -112,7 +109,7 @@ fun ZoomableContentView( content: BaseMediaContent, images: ImmutableList = remember(content) { persistentListOf(content) }, roundedCorner: Boolean, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, ) { var dialogOpen by remember(content) { mutableStateOf(false) } @@ -124,18 +121,14 @@ fun ZoomableContentView( val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode) val isOrientationLocked = DeviceUtils.screenOrientationIsLocked(LocalContext.current) - val contentScale = - if (isFiniteHeight) { - ContentScale.Fit - } else { - ContentScale.FillWidth - } - when (content) { is MediaUrlImage -> SensitivityWarning(content.contentWarning != null, accountViewModel) { TwoSecondController(content) { controllerVisible -> - val mainImageModifier = Modifier.fillMaxWidth().clickable { dialogOpen = true } + val mainImageModifier = + Modifier + .fillMaxWidth() + .clickable { dialogOpen = true } val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth() UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel) @@ -153,7 +146,7 @@ fun ZoomableContentView( dimensions = content.dim, blurhash = content.blurhash, roundedCorner = roundedCorner, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, nostrUriCallback = content.uri, onDialog = { dialogOpen = true @@ -167,7 +160,10 @@ fun ZoomableContentView( } is MediaLocalImage -> TwoSecondController(content) { controllerVisible -> - val mainImageModifier = Modifier.fillMaxWidth().clickable { dialogOpen = true } + val mainImageModifier = + Modifier + .fillMaxWidth() + .clickable { dialogOpen = true } val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth() LocalImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel) @@ -182,7 +178,7 @@ fun ZoomableContentView( artworkUri = content.artworkUri, authorName = content.authorName, roundedCorner = roundedCorner, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, nostrUriCallback = content.uri, onDialog = { dialogOpen = true }, accountViewModel = accountViewModel, @@ -204,67 +200,6 @@ fun ZoomableContentView( } } -@Composable -fun GalleryContentView( - content: BaseMediaContent, - roundedCorner: Boolean, - isFiniteHeight: Boolean, - accountViewModel: AccountViewModel, -) { - when (content) { - is MediaUrlImage -> - SensitivityWarning(content.contentWarning != null, accountViewModel) { - TwoSecondController(content) { controllerVisible -> - val mainImageModifier = Modifier.fillMaxWidth() - val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth() - - UrlImageView(content, ContentScale.Crop, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel) - } - } - is MediaUrlVideo -> - SensitivityWarning(content.contentWarning != null, accountViewModel) { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - VideoView( - videoUri = content.url, - mimeType = content.mimeType, - title = content.description, - artworkUri = content.artworkUri, - borderModifier = MaterialTheme.colorScheme.videoGalleryModifier, - authorName = content.authorName, - dimensions = content.dim, - blurhash = content.blurhash, - isFiniteHeight = isFiniteHeight, - nostrUriCallback = content.uri, - accountViewModel = accountViewModel, - ) - } - } - is MediaLocalImage -> - TwoSecondController(content) { controllerVisible -> - val mainImageModifier = Modifier.fillMaxWidth() - val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth() - - LocalImageView(content, ContentScale.Crop, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel) - } - is MediaLocalVideo -> - content.localFile?.let { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - VideoView( - videoUri = it.toUri().toString(), - mimeType = content.mimeType, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - borderModifier = MaterialTheme.colorScheme.videoGalleryModifier, - isFiniteHeight = isFiniteHeight, - nostrUriCallback = content.uri, - accountViewModel = accountViewModel, - ) - } - } - } -} - @Composable fun TwoSecondController( content: BaseMediaContent, @@ -389,7 +324,7 @@ fun UrlImageView( accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, ) { - val ratio = remember(content) { aspectRatio(content.dim) } + val ratio = content.dim?.aspectRatio() val showImage = remember { @@ -412,11 +347,18 @@ fun UrlImageView( -> { if (content.blurhash != null) { if (ratio != null) { + val modifier = + if (contentScale == ContentScale.Crop) { + loadedImageModifier.clickable { showImage.value = true } + } else { + loadedImageModifier.aspectRatio(ratio).clickable { showImage.value = true } + } + DisplayBlurHash( content.blurhash, content.description, ContentScale.Crop, - loadedImageModifier.aspectRatio(ratio), + modifier, ) } else { DisplayBlurHash( @@ -436,29 +378,25 @@ fun UrlImageView( is AsyncImagePainter.State.Success -> { SubcomposeAsyncImageContent(loadedImageModifier) - AnimatedVisibility( - visible = controllerVisible.value, - modifier = Modifier.align(Alignment.TopEnd), - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, - ) { - Box(Modifier.align(Alignment.TopEnd), contentAlignment = Alignment.TopEnd) { - ShowHash(content) - } - } + ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd)) } else -> {} } } } else { if (content.blurhash != null && ratio != null) { + val modifier = + if (contentScale == ContentScale.Crop) { + loadedImageModifier.clickable { showImage.value = true } + } else { + loadedImageModifier.aspectRatio(ratio).clickable { showImage.value = true } + } + DisplayBlurHash( content.blurhash, content.description, - ContentScale.Crop, - loadedImageModifier - .aspectRatio(ratio) - .clickable { showImage.value = true }, + contentScale, + modifier, ) IconButton( modifier = Modifier.size(Size75dp), @@ -497,7 +435,6 @@ fun ImageUrlWithDownloadButton( withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") - appendInlineContent("inlineContent", "[icon]") pop() } @@ -505,33 +442,57 @@ fun ImageUrlWithDownloadButton( } } - val inlineContent = mapOf("inlineContent" to InlineDownloadIcon(showImage)) + val pressIndicator = + remember { + Modifier + .fillMaxWidth() + .clickable { runCatching { uri.openUri(url) } } + } - val pressIndicator = remember { Modifier.fillMaxWidth().clickable { runCatching { uri.openUri(url) } } } - - Text( - text = annotatedTermsString, - modifier = pressIndicator, - inlineContent = inlineContent, - ) + Row( + modifier = + Modifier + .width(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = annotatedTermsString, + modifier = + pressIndicator + .weight(1f, fill = false), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + InlineDownloadIcon(showImage) + } } @Composable private fun InlineDownloadIcon(showImage: MutableState) = - InlineTextContent( - Placeholder( - width = Font17SP, - height = Font17SP, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), + IconButton( + modifier = Modifier.size(Size20dp), + onClick = { showImage.value = true }, ) { - IconButton( - modifier = Modifier.size(Size20dp), - onClick = { showImage.value = true }, - ) { - DownloadForOfflineIcon(Size24dp) + DownloadForOfflineIcon(Size24dp) + } + +@Composable +fun ShowHashAnimated( + content: MediaUrlImage, + controllerVisible: MutableState, + modifier: Modifier, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(modifier, contentAlignment = Alignment.TopEnd) { + ShowHash(content) } } +} @Composable fun ShowHash(content: MediaUrlContent) { @@ -552,44 +513,14 @@ fun ShowHash(content: MediaUrlContent) { verifiedHash?.let { HashVerificationSymbol(it) } } -fun aspectRatio(dim: String?): Float? { +fun aspectRatio(dim: Dimension?): Float? { if (dim == null) return null - if (dim == "0x0") return null - val parts = dim.split("x") - if (parts.size != 2) return null - - return try { - val width = parts[0].toFloat() - val height = parts[1].toFloat() - - if (width < 0.1 || height < 0.1) { - null - } else { - width / height - } - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } + return dim.width.toFloat() / dim.height.toFloat() } @Composable -private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) { - var cnt by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - delay(200) - cnt = content - } - } - - cnt?.let { DisplayUrlWithLoadingSymbolWait(it) } -} - -@Composable -private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) { +fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) { val uri = LocalUriHandler.current val primary = MaterialTheme.colorScheme.primary @@ -613,7 +544,6 @@ private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) { withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") - appendInlineContent("inlineContent", "[icon]") pop() } @@ -621,8 +551,6 @@ private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) { } } - val inlineContent = mapOf("inlineContent" to InlineLoadingIcon()) - val pressIndicator = remember { if (content is MediaUrlContent) { @@ -632,24 +560,26 @@ private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) { } } - Text( - text = annotatedTermsString, - modifier = pressIndicator, - inlineContent = inlineContent, - ) + Row( + modifier = + Modifier + .width(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = annotatedTermsString, + modifier = + pressIndicator + .weight(1f, fill = false), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + InlineLoadingIcon() + } } @Composable -private fun InlineLoadingIcon() = - InlineTextContent( - Placeholder( - width = Font17SP, - height = Font17SP, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - ) { - LoadingAnimation() - } +private fun InlineLoadingIcon() = LoadingAnimation() @Composable fun DisplayBlurHash( @@ -660,17 +590,8 @@ fun DisplayBlurHash( ) { if (blurhash == null) return - val context = LocalContext.current - val model = - remember { - BlurHashRequester.imageRequest( - context, - blurhash, - ) - } - AsyncImage( - model = model, + model = Blurhash(blurhash), contentDescription = description, contentScale = contentScale, modifier = modifier, @@ -719,7 +640,7 @@ fun ShareImageAction( videoUri: String?, postNostrUri: String?, blurhash: String?, - dim: String?, + dim: Dimension?, hash: String?, mimeType: String?, onDismiss: () -> Unit, @@ -755,10 +676,10 @@ fun ShareImageAction( text = { Text(stringRes(R.string.add_media_to_gallery)) }, onClick = { if (videoUri != null) { - var n19 = Nip19Bech32.uriToRoute(postNostrUri)?.entity as? Nip19Bech32.NEvent + val n19 = Nip19Bech32.uriToRoute(postNostrUri)?.entity as? Nip19Bech32.NEvent if (n19 != null) { accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay[0], blurhash, dim, hash, mimeType) // TODO Whole list or first? - accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) + accountViewModel.toast(R.string.media_added, R.string.media_added_to_profile_gallery) } } @@ -769,7 +690,6 @@ fun ShareImageAction( } } -@OptIn(ExperimentalCoilApi::class) private suspend fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null 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 ae6a55063..273dbfc83 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 @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -85,7 +86,7 @@ class MarkdownMediaRenderer( ) { if (canPreview) { val content = - parser.parseMediaUrl( + parser.createMediaContent( fullUrl = uri, eventTags = tags ?: EmptyTagList, description = title?.ifEmpty { null } ?: startOfText, @@ -95,7 +96,7 @@ class MarkdownMediaRenderer( ZoomableContentView( content = content, roundedCorner = true, - isFiniteHeight = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) } @@ -109,7 +110,7 @@ class MarkdownMediaRenderer( uri: String, richTextStringBuilder: RichTextString.Builder, ) { - val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri) + val content = parser.createMediaContent(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri) if (canPreview) { if (content != null) { @@ -117,7 +118,7 @@ class MarkdownMediaRenderer( ZoomableContentView( content = content, roundedCorner = true, - isFiniteHeight = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) } @@ -183,7 +184,7 @@ class MarkdownMediaRenderer( richTextStringBuilder: RichTextString.Builder, ) { val tagWithoutHash = tag.removePrefix("#") - renderAsCompleteLink(tag, "nostr:Hashtag?id=$tagWithoutHash", richTextStringBuilder) + renderAsCompleteLink(tag, "nostr:nashtag?id=$tagWithoutHash", richTextStringBuilder) val hashtagIcon: HashtagIcon? = checkForHashtagWithIcon(tagWithoutHash) if (hashtagIcon != null) { 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 be8680f15..5871ba75b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.dal +import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.LiveActivitiesEvent @@ -33,6 +35,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils class FilterByListParams( val isGlobal: Boolean, val isHiddenList: Boolean, + val isAroundMe: Boolean, val followLists: Account.LiveFollowList?, val hiddenLists: Account.LiveHiddenUsers, val now: Long = TimeUtils.oneMinuteFromNow(), @@ -43,12 +46,18 @@ class FilterByListParams( fun isEventInList(noteEvent: Event): Boolean { if (followLists == null) return false + if (isAroundMe && followLists.geotags.isEmpty()) return false return if (noteEvent is LiveActivitiesEvent) { noteEvent.participantsIntersect(followLists.authors) || noteEvent.isTaggedHashes(followLists.hashtags) || noteEvent.isTaggedGeoHashes(followLists.geotags) || noteEvent.isTaggedAddressableNotes(followLists.addresses) + } else if (noteEvent is CommentEvent) { + // ignore follows and checks only the root scope + noteEvent.isTaggedHashes(followLists.hashtags) || + noteEvent.isTaggedGeoHashes(followLists.geotags) || + noteEvent.isTaggedAddressableNotes(followLists.addresses) } else { noteEvent.pubKey in followLists.authors || noteEvent.isTaggedHashes(followLists.hashtags) || @@ -95,6 +104,7 @@ class FilterByListParams( FilterByListParams( isGlobal = selectedListName == GLOBAL_FOLLOWS, isHiddenList = showHiddenKey(selectedListName, userHex), + isAroundMe = selectedListName == AROUND_ME, followLists = followLists, hiddenLists = hiddenUsers, ) 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 d5e737ac2..2132975b2 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 @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.ChannelMessageEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.PeopleListEvent @@ -75,6 +76,7 @@ class HomeConversationsFeedFilter( it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || + it.event is CommentEvent || it.event is LiveActivitiesChatMessageEvent ) && filterParams.match(it.event) && 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 f650584fe..b6bcc7d9e 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 @@ -26,8 +26,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.PeopleListEvent @@ -98,6 +100,8 @@ class HomeNewThreadFeedFilter( (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || + noteEvent is InteractiveStoryPrologueEvent || + noteEvent is CommentEvent || noteEvent is AudioTrackEvent || noteEvent is AudioHeaderEvent ) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt index 2ee4d12b8..bc80e6fb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt @@ -27,19 +27,28 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.model.ThreadLevelCalculator import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.toImmutableSet @Immutable class ThreadFeedFilter( val account: Account, - val noteId: String, + private val noteId: String, ) : FeedFilter() { override fun feedKey(): String = noteId override fun feed(): List { val cachedSignatures: MutableMap = mutableMapOf() val followingKeySet = account.liveKind3Follows.value.authors - val eventsToWatch = ThreadAssembler().findThreadFor(noteId) - val eventsInHex = eventsToWatch.map { it.idHex }.toSet() + val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList() + + // Filter out drafts made by other accounts on device + val filteredEvents = + eventsToWatch.allNotes + .filter { !it.isDraft() || (it.author?.pubkeyHex == account.userProfile().pubkeyHex) } + .toImmutableSet() + val filteredThreadInfo = ThreadAssembler.ThreadInfo(eventsToWatch.root, filteredEvents) + + val eventsInHex = filteredThreadInfo.allNotes.map { it.idHex }.toSet() val now = TimeUtils.now() // Currently orders by date of each event, descending, at each level of the reply stack @@ -56,6 +65,6 @@ class ThreadFeedFilter( ).signature } - return eventsToWatch.sortedWith(order) + return filteredThreadInfo.allNotes.sortedWith(order) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt index 34693e20f..9d1cc1e5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt @@ -26,7 +26,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.events.VideoEvent class UserProfileGalleryFeedFilter( val user: User, @@ -65,7 +67,7 @@ class UserProfileGalleryFeedFilter( ): Boolean { val noteEvent = it.event return ( - (it.event?.pubKey() == user.pubkeyHex && noteEvent is ProfileGalleryEntryEvent) && noteEvent.hasUrl() && noteEvent.hasFromEvent() // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) + (it.event?.pubKey() == user.pubkeyHex && (noteEvent is PictureEvent || noteEvent is VideoEvent || (noteEvent is ProfileGalleryEntryEvent) && noteEvent.hasUrl() && noteEvent.hasFromEvent())) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ) && params.match(noteEvent) && account.isAcceptable(it) 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 5bc32e006..0f8b99305 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 @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.RepostEvent @@ -73,6 +74,7 @@ class UserProfileNewThreadFeedFilter( 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 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 6cdacc81c..88febe38f 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 @@ -20,15 +20,20 @@ */ package com.vitorpamplona.amethyst.ui.dal +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isImageOrVideoUrl 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.service.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET +import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent +import com.vitorpamplona.quartz.events.VideoMeta import com.vitorpamplona.quartz.events.VideoVerticalEvent class VideoFeedFilter( @@ -46,7 +51,10 @@ class VideoFeedFilter( val notes = LocalCache.notes.filterIntoSet { _, it -> acceptableEvent(it, params) - } + } + + LocalCache.addressables.filterIntoSet { _, it -> + acceptableEvent(it, params) + } return sort(notes) } @@ -59,20 +67,48 @@ class VideoFeedFilter( return collection.filterTo(HashSet()) { acceptableEvent(it, params) } } + fun acceptableUrls( + baseUrls: List, + mimeType: String?, + ): Boolean { + // we don't have an youtube player + val urls = baseUrls.filter { !it.contains("youtu.be") } + + val isSupportedMimeType = mimeType?.let { SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it) } ?: false + + return urls.isNotEmpty() && (urls.any { isImageOrVideoUrl(it) } || isSupportedMimeType) + } + + fun acceptableiMetas(iMetas: List): Boolean = + iMetas.any { + !it.url.contains("youtu.be") && (isImageOrVideoUrl(it.url) || (it.mimeType == null || SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it.mimeType))) + } + + fun acceptanceEvent(noteEvent: FileHeaderEvent) = acceptableUrls(noteEvent.urls(), noteEvent.mimeType()) + + fun acceptanceEvent(noteEvent: VideoVerticalEvent) = acceptableiMetas(noteEvent.imetaTags()) + + fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableiMetas(noteEvent.imetaTags()) + fun acceptableEvent( - it: Note, + note: Note, params: FilterByListParams, ): Boolean { - val noteEvent = it.event + val noteEvent = note.event + + if (noteEvent is AddressableEvent && note !is AddressableNote) { + return false + } return ( - (noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || - (noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || - (noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || - (noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) + (noteEvent is FileHeaderEvent && acceptanceEvent(noteEvent)) || + (noteEvent is VideoVerticalEvent && acceptanceEvent(noteEvent)) || + (noteEvent is VideoHorizontalEvent && acceptanceEvent(noteEvent)) || + (noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || + noteEvent is PictureEvent ) && params.match(noteEvent) && - (params.isHiddenList || account.isAcceptable(it)) + (params.isHiddenList || account.isAcceptable(note)) } fun buildFilterParams(account: Account): FilterByListParams = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt index c1dd8c15d..206c4f0bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt @@ -23,10 +23,7 @@ package com.vitorpamplona.amethyst.ui.feeds import android.util.Log import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index 5c0a52a53..44fc509eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.navigation.INav @@ -48,6 +49,8 @@ fun FeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + LazyColumn( contentPadding = FeedPadding, state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index a164733ce..5dcff2e06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -38,21 +38,19 @@ private data class ScrollState( ) object ScrollStateKeys { - const val GLOBAL_SCREEN = "Global" - const val NOTIFICATION_SCREEN = "Notifications" - const val VIDEO_SCREEN = "Video" - const val DISCOVER_SCREEN = "Discover" - val HOME_FOLLOWS = Route.Home.base + "Follows" - val HOME_REPLIES = Route.Home.base + "FollowsReplies" - val PROFILE_GALLERY = Route.Home.base + "ProfileGallery" + const val NOTIFICATION_SCREEN = "NotificationsFeed" + const val VIDEO_SCREEN = "VideoFeed" + val HOME_FOLLOWS = Route.Home.base + "FollowsFeed" + val HOME_REPLIES = Route.Home.base + "FollowsRepliesFeed" + val PROFILE_GALLERY = Route.Home.base + "ProfileGalleryFeed" - val DRAFTS = Route.Home.base + "Drafts" + val DRAFTS = Route.Home.base + "DraftsFeed" - val DISCOVER_CONTENT = Route.Home.base + "DiscoverContent" - val DISCOVER_MARKETPLACE = Route.Home.base + "Marketplace" - val DISCOVER_LIVE = Route.Home.base + "Live" - val DISCOVER_COMMUNITY = Route.Home.base + "Communities" - val DISCOVER_CHATS = Route.Home.base + "Chats" + val DISCOVER_CONTENT = Route.Home.base + "DiscoverContentFeed" + val DISCOVER_MARKETPLACE = Route.Home.base + "MarketplaceFeed" + val DISCOVER_LIVE = Route.Home.base + "LiveFeed" + val DISCOVER_COMMUNITY = Route.Home.base + "CommunitiesFeed" + val DISCOVER_CHATS = Route.Home.base + "ChatsFeed" } object PagerStateKeys { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index d1965e062..04b1a8d79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.feeds -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.pager.PagerState @@ -75,7 +74,6 @@ fun WatchScrollToTop( } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun WatchScrollToTop( videoFeedContentState: FeedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt index d9b23f2ae..2db9c2ed5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt @@ -109,10 +109,11 @@ fun LeftPictureLayout( onTitleRow: @Composable RowScope.() -> Unit, onDescription: @Composable () -> Unit, onBottomRow: @Composable RowScope.() -> Unit, + imageFraction: Float = 0.25f, ) { - Row(Modifier.aspectRatio(ratio = 4f)) { + Row(Modifier.aspectRatio(ratio = 1 / imageFraction)) { Column( - modifier = Modifier.fillMaxWidth(0.25f).aspectRatio(ratio = 1f), + modifier = Modifier.fillMaxWidth(imageFraction).aspectRatio(ratio = 1f), ) { onImage() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 6aeb227f2..c2030ee9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -73,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.forEach import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) 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 841c4f65e..b85108317 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 @@ -49,6 +49,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView import com.vitorpamplona.amethyst.ui.components.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.components.DisplayNotifyMessages import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel @@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.encoders.Nip19Bech32 import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.net.URI import java.net.URLDecoder fun NavBackStackEntry.id(): String? = arguments?.getString("id") @@ -294,6 +296,19 @@ fun AppNavigation( NIP47SetupScreen(accountViewModel, nav, nip47) } + composable( + Route.EditRelays.route, + content = { + val relayToAdd = it.arguments?.getString("toAdd") + + AllRelayListView( + relayToAdd = relayToAdd, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + composable( Route.NewPost.route, Route.NewPost.arguments, @@ -313,6 +328,7 @@ fun AppNavigation( val version = it.arguments?.getString("version") val draft = it.arguments?.getString("draft") val enableMessageInterface = it.arguments?.getBoolean("enableMessageInterface") ?: false + val enableGeolocation = it.arguments?.getBoolean("enableGeolocation") ?: false NewPostScreen( message = draftMessage, @@ -323,6 +339,7 @@ fun AppNavigation( version = version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draft = draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, enableMessageInterface = enableMessageInterface, + enableGeolocation = enableGeolocation, accountViewModel = accountViewModel, nav = nav, ) @@ -342,6 +359,14 @@ private fun NavigateIfIntentRequested( accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, ) { + accountViewModel.firstRoute?.let { + accountViewModel.firstRoute = null + val currentRoute = getRouteWithArguments(nav.controller) + if (!isSameRoute(currentRoute, it)) { + nav.newStack(it) + } + } + val activity = LocalContext.current.getActivity() if (activity.intent.action == Intent.ACTION_SEND) { @@ -387,12 +412,17 @@ private fun NavigateIfIntentRequested( LaunchedEffect(intentNextPage) { if (actionableNextPage != null) { - actionableNextPage?.let { - val currentRoute = getRouteWithArguments(nav.controller) - if (!isSameRoute(currentRoute, it)) { - nav.newStack(it) + actionableNextPage?.let { nextRoute -> + val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() + if (npub != null && accountStateViewModel.currentAccount() != npub) { + accountStateViewModel.switchUserSync(npub, nextRoute) + } else { + val currentRoute = getRouteWithArguments(nav.controller) + if (!isSameRoute(currentRoute, nextRoute)) { + nav.newStack(nextRoute) + } + actionableNextPage = null } - actionableNextPage = null } } else if (intentNextPage.contains("ncryptsec1")) { // login functions @@ -432,14 +462,22 @@ private fun NavigateIfIntentRequested( } } else { val uri = intent.data?.toString() + if (!uri.isNullOrBlank()) { // navigation functions val newPage = uriToRoute(uri) if (newPage != null) { - val currentRoute = getRouteWithArguments(nav.controller) - if (!isSameRoute(currentRoute, newPage)) { - nav.newStack(newPage) + scope.launch { + val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() + if (npub != null && accountStateViewModel.currentAccount() != npub) { + accountStateViewModel.switchUserSync(npub, newPage) + } else { + val currentRoute = getRouteWithArguments(nav.controller) + if (!isSameRoute(currentRoute, newPage)) { + nav.newStack(newPage) + } + } } } else if (uri.contains("ncryptsec")) { // login functions @@ -501,3 +539,14 @@ val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), ta val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f) val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f) + +fun URI.findParameterValue(parameterName: String): String? = + rawQuery + ?.split('&') + ?.map { + val parts = it.split('=') + val name = parts.firstOrNull() ?: "" + val value = parts.drop(1).firstOrNull() ?: "" + Pair(name, value) + }?.firstOrNull { it.first == parameterName } + ?.second 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 c3245b468..8b32e1179 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 @@ -89,7 +89,6 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView -import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView import com.vitorpamplona.amethyst.ui.components.ClickableText import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage @@ -112,7 +111,6 @@ import com.vitorpamplona.amethyst.ui.theme.drawerSpacing 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.RelayPool import com.vitorpamplona.ammolite.relays.RelayPoolStatus import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey @@ -437,7 +435,6 @@ fun ListContent( ) { val route = remember(accountViewModel) { "User/${accountViewModel.userProfile().pubkeyHex}" } - var wantsToEditRelays by remember { mutableStateOf(false) } var editMediaServers by remember { mutableStateOf(false) } var backupDialogOpen by remember { mutableStateOf(false) } @@ -483,7 +480,7 @@ fun ListContent( accountViewModel = accountViewModel, onClick = { nav.closeDrawer() - wantsToEditRelays = true + nav.nav(Route.EditRelays.base) }, ) @@ -545,9 +542,6 @@ fun ListContent( ) } - if (wantsToEditRelays) { - AllRelayListView({ wantsToEditRelays = false }, accountViewModel = accountViewModel, nav = nav) - } if (editMediaServers) { MediaServersListView({ editMediaServers = false }, accountViewModel = accountViewModel, nav = nav) } @@ -576,7 +570,7 @@ fun ListContent( @Composable private fun RelayStatus(accountViewModel: AccountViewModel) { - val connectedRelaysText by RelayPool.statusFlow.collectAsStateWithLifecycle(RelayPoolStatus(0, 0)) + val connectedRelaysText by accountViewModel.relayStatusFlow().collectAsStateWithLifecycle(RelayPoolStatus(0, 0)) RenderRelayStatus(connectedRelaysText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt index a1855640d..0f7af14ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt @@ -54,7 +54,8 @@ import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.service.LocationState +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.LoadCityName import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition import com.vitorpamplona.amethyst.ui.screen.CommunityName @@ -71,7 +72,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.PeopleListEvent import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.flow.map @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -104,6 +104,12 @@ fun FeedFilterSpinner( } } + val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) + + LaunchedEffect(locationPermissionState.status.isGranted) { + Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted) + } + Box( modifier = modifier, contentAlignment = Alignment.Center, @@ -115,27 +121,45 @@ fun FeedFilterSpinner( Text(currentText) if (selected is AroundMeFeedDefinition) { - val locationPermissionState = - rememberPermissionState( - Manifest.permission.ACCESS_COARSE_LOCATION, - ) - if (!locationPermissionState.status.isGranted) { LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } + + Text( + text = stringRes(R.string.lack_location_permissions), + fontSize = 12.sp, + lineHeight = 12.sp, + ) } else { val location by Amethyst.instance.locationManager.geohashStateFlow - .collectAsStateWithLifecycle(null) + .collectAsStateWithLifecycle() - location?.let { - LoadCityName( - geohashStr = it, - onLoading = { - Spacer(modifier = StdHorzSpacer) - LoadingAnimation() - }, - ) { cityName -> + when (val myLocation = location) { + is LocationState.LocationResult.Success -> { + LoadCityName( + geohashStr = myLocation.geoHash.toString(), + onLoading = { + Spacer(modifier = StdHorzSpacer) + LoadingAnimation() + }, + ) { cityName -> + Text( + text = "($cityName)", + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + } + + LocationState.LocationResult.LackPermission -> { Text( - text = "($cityName)", + text = stringRes(R.string.lack_location_permissions), + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + LocationState.LocationResult.Loading -> { + Text( + text = stringRes(R.string.loading_location), fontSize = 12.sp, lineHeight = 12.sp, ) 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 9cb92b8bf..c1a81f3df 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 @@ -216,6 +216,21 @@ sealed class Route( icon = R.drawable.ic_settings, ) + object EditRelays : + Route( + route = "EditRelays?toAdd={toAdd}", + icon = R.drawable.ic_globe, + contentDescriptor = R.string.relays, + arguments = + listOf( + navArgument("toAdd") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ).toImmutableList(), + ) + object NIP47Setup : Route( route = "NIP47Setup?nip47={nip47}", @@ -232,7 +247,7 @@ sealed class Route( object NewPost : Route( - route = "NewPost?message={message}&attachment={attachment}&baseReplyTo={baseReplyTo}"e={quote}&fork={fork}&version={version}&draft={draft}&enableMessageInterface={enableMessageInterface}", + route = "NewPost?message={message}&attachment={attachment}&baseReplyTo={baseReplyTo}"e={quote}&fork={fork}&version={version}&draft={draft}&enableGeolocation={enableGeolocation}&enableMessageInterface={enableMessageInterface}", icon = R.drawable.ic_moments, arguments = listOf( @@ -243,6 +258,7 @@ sealed class Route( navArgument("fork") { type = NavType.StringType }, navArgument("version") { type = NavType.StringType }, navArgument("draft") { type = NavType.StringType }, + navArgument("enableGeolocation") { type = NavType.BoolType }, navArgument("enableMessageInterface") { type = NavType.BoolType }, ).toImmutableList(), ) @@ -314,6 +330,7 @@ fun buildNewPostRoute( fork: String? = null, version: String? = null, draft: String? = null, + enableGeolocation: Boolean = false, enableMessageInterface: Boolean = false, ): String = "NewPost?" + @@ -324,4 +341,5 @@ fun buildNewPostRoute( "fork=${fork ?: ""}&" + "version=${version ?: ""}&" + "draft=${draft ?: ""}&" + + "enableGeolocation=$enableGeolocation&" + "enableMessageInterface=$enableMessageInterface" 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 c4e201406..fa5221237 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 @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,6 +34,7 @@ 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.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -40,7 +42,6 @@ import androidx.compose.runtime.Composable 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.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -55,13 +56,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -82,16 +87,21 @@ 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 +import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.RowColSpacing -import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.placeholderText +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.events.AppDefinitionEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent @@ -509,7 +519,7 @@ fun RenderLiveActivityThumb( .align(BottomStart), ) { if (participantUsers.isNotEmpty()) { - Gallery(participantUsers, accountViewModel) + Gallery(participantUsers, Modifier, accountViewModel) } } } @@ -542,6 +552,8 @@ data class DVMCard( val name: String, val description: String?, val cover: String?, + val amount: String?, + val personalized: Boolean?, ) @Composable @@ -621,24 +633,20 @@ fun RenderCommunitiesThumb( ) }, onDescription = { - card.description?.let { - Spacer(modifier = StdVertSpacer) - Row { - Text( - text = it, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, - ) - } - } + Text( + text = card.description ?: stringRes(R.string.community_about_topic, card.name), + color = MaterialTheme.colorScheme.grayText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + lineHeight = 18.sp, + modifier = HalfTopPadding, + ) }, onBottomRow = { - Spacer(modifier = StdVertSpacer) LoadModerators(card.moderators, baseNote, accountViewModel) { participantUsers -> if (participantUsers.isNotEmpty()) { - Gallery(participantUsers, accountViewModel) + Gallery(participantUsers, HalfTopPadding, accountViewModel) } } }, @@ -770,6 +778,7 @@ fun RenderContentDVMThumb( val card = observeAppDefinition(appDefinitionNote = baseNote) LeftPictureLayout( + imageFraction = 0.20f, onImage = { card.cover?.let { Box(contentAlignment = BottomStart) { @@ -802,11 +811,10 @@ fun RenderContentDVMThumb( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - Spacer(modifier = StdVertSpacer) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = RowColSpacing, + horizontalArrangement = RowColSpacing5dp, ) { LikeReaction( baseNote = baseNote, @@ -825,19 +833,81 @@ fun RenderContentDVMThumb( }, onDescription = { card.description?.let { - Spacer(modifier = StdVertSpacer) - Row { - Text( - text = it, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, - ) - } + Text( + text = it, + color = MaterialTheme.colorScheme.grayText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + lineHeight = 16.sp, + modifier = HalfTopPadding, + ) } }, onBottomRow = { + card.amount?.let { + var color = Color.DarkGray + var amount = it + if (card.amount == "free" || card.amount == "0") { + color = MaterialTheme.colorScheme.secondary + amount = "Free" + } else if (card.amount == "flexible") { + color = MaterialTheme.colorScheme.primaryContainer + amount = "Flexible" + } else if (card.amount == "") { + color = MaterialTheme.colorScheme.grayText + amount = "Unknown" + } else { + color = MaterialTheme.colorScheme.primary + amount = card.amount + " Sats" + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.Right, + ) { + Text( + textAlign = TextAlign.End, + text = " $amount ", + color = color, + maxLines = 3, + modifier = + Modifier + .weight(1f, fill = false) + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) + } + } + Spacer(modifier = StdHorzSpacer) + card.personalized?.let { + var color = Color.DarkGray + var name = "generic" + if (card.personalized == true) { + color = MaterialTheme.colorScheme.bitcoinColor + name = "Personalized" + } else { + color = MaterialTheme.colorScheme.nip05 + name = "Generic" + } + Spacer(modifier = StdVertSpacer) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.Right, + ) { + Text( + textAlign = TextAlign.End, + text = " $name ", + color = color, + maxLines = 3, + modifier = + Modifier + .padding(start = 4.dp) + .weight(1f, fill = false) + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) + } + } }, ) } @@ -865,10 +935,10 @@ fun RenderChannelThumb( val channelUpdates by channel.live.observeAsState() val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" } - val description = remember(channelUpdates) { channelUpdates?.channel?.summary() } - val cover by + val description = remember(channelUpdates) { channelUpdates?.channel?.summary()?.ifBlank { null } } + var cover by remember(channelUpdates) { - derivedStateOf { channelUpdates?.channel?.profilePicture()?.ifBlank { null } } + mutableStateOf(channelUpdates?.channel?.profilePicture()?.ifBlank { null }) } var participantUsers by @@ -916,6 +986,11 @@ fun RenderChannelThumb( Modifier .fillMaxSize() .clip(QuoteBorder), + onState = { + if (it is AsyncImagePainter.State.Error) { + cover = null + } + }, ) } ?: run { DisplayAuthorBanner(baseNote) } }, @@ -948,20 +1023,19 @@ fun RenderChannelThumb( ) }, onDescription = { - description?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, - ) - } + Text( + text = description ?: stringRes(R.string.chat_about_topic, name), + color = MaterialTheme.colorScheme.grayText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + lineHeight = 18.sp, + modifier = HalfTopPadding, + ) }, onBottomRow = { if (participantUsers.isNotEmpty()) { - Spacer(modifier = StdVertSpacer) - Gallery(participantUsers, accountViewModel) + Gallery(participantUsers, HalfTopPadding, accountViewModel) } }, ) @@ -971,17 +1045,18 @@ fun RenderChannelThumb( @Composable fun Gallery( users: ImmutableList, + modifier: Modifier, accountViewModel: AccountViewModel, ) { - FlowRow(verticalArrangement = Arrangement.Center) { - users.take(6).forEach { ClickableUserPicture(it, Size35dp, accountViewModel) } + FlowRow(modifier, verticalArrangement = Arrangement.Center) { + users.take(6).forEach { ClickableUserPicture(it, Size25dp, accountViewModel) } if (users.size > 6) { Text( text = " + " + showCount(users.size - 6), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(CenterVertically), + modifier = Modifier.padding(start = 3.dp).align(CenterVertically), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt new file mode 100644 index 000000000..a9a691212 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.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.note + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +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.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size16dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +@Preview +fun ErrorMessageContentPreview() { + ThemeComparisonColumn { + ErrorMessageDialog( + title = "Title", + textContent = "This is an Error Message", + onClickStartMessage = { }, + onDismiss = { }, + ) + } +} + +@Composable +fun ErrorMessageDialog( + title: String, + textContent: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickStartMessage: (() -> Unit)? = null, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { SelectionContainer { Text(textContent) } }, + confirmButton = { + Row( + modifier = Modifier.padding(vertical = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + onClickStartMessage?.let { + TextButton(onClick = onClickStartMessage) { + Icon( + painter = painterResource(R.drawable.ic_dm), + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringRes(R.string.error_dialog_talk_to_user)) + } + } + Button( + onClick = onDismiss, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = Size16dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringRes(R.string.error_dialog_button_ok)) + } + } + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index 73e3acfbd..9bbd04720 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -31,7 +31,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.AddReaction +import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Clear @@ -46,7 +46,6 @@ import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.AddReaction -import androidx.compose.material.icons.outlined.ArrowForwardIos import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material3.Icon @@ -469,7 +468,7 @@ fun PinIcon( ) { Icon( imageVector = Icons.Default.PushPin, - contentDescription = null, + contentDescription = stringRes(id = R.string.accessibility_pushpin), modifier = modifier, tint = tint, ) @@ -620,7 +619,7 @@ fun ZapSplitPreview() { tint = BitcoinOrange, ) Icon( - imageVector = Icons.Outlined.ArrowForwardIos, + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, contentDescription = stringRes(id = R.string.zaps), modifier = Modifier 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 8cc0d2c7e..d6007fb01 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 @@ -215,6 +215,7 @@ fun LoadCityName( CachedGeoLocations .geoLocate(geohashStr, geoHash.toLocation(), context) ?.ifBlank { null } + if (newCityName != null && newCityName != cityName) { cityName = newCityName } 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 new file mode 100644 index 000000000..1d78a5f4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiUserMessageDialog.kt @@ -0,0 +1,239 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +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.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.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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 +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user +import com.vitorpamplona.amethyst.ui.navigation.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size16dp +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size30Modifier +import com.vitorpamplona.amethyst.ui.theme.Size30dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +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 + +@Composable +@Preview +fun MultiUserErrorMessageContentPreview() { + val accountViewModel = mockAccountViewModel() + val nav = EmptyNav + + var user1: User? = null + var user2: User? = null + var user3: User? = null + + runBlocking { + withContext(Dispatchers.IO) { + user1 = LocalCache.getOrCreateUser("aabbccaabbccaabbcc") + user2 = LocalCache.getOrCreateUser("bbbccabbbccabbbcca") + user3 = LocalCache.getOrCreateUser("ccaadaccaadaccaada") + } + } + + val model: UserBasedErrorMessageViewModel = viewModel() + model.add("Could not fetch invoice from https://minibits.cash/.well-known/lnurlp/victorieeman: There are too many unpaid invoices for this name.", user1) + model.add("No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps.", user2) + model.add("Could not fetch invoice", user3) + + ThemeComparisonColumn { + MultiUserErrorMessageDialogInner( + title = "Couldn't not zap", + model = model, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Stable +class UserBasedErrorMessageViewModel : ViewModel() { + val errors = MutableStateFlow>(emptyList()) + val hasErrors = errors.map { it.isNotEmpty() } + + fun add( + message: String, + user: User?, + ) { + add(UserBasedErrorMessage(message, user)) + } + + fun add(newError: UserBasedErrorMessage) { + errors.update { + it + newError + } + } + + fun clearErrors() { + errors.update { + emptyList() + } + } +} + +class UserBasedErrorMessage( + val error: String, + val user: User?, +) + +@Composable +fun MultiUserErrorMessageDialog( + title: String, + model: UserBasedErrorMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val hasErrors by model.hasErrors.collectAsStateWithLifecycle(false) + if (hasErrors) { + MultiUserErrorMessageDialogInner(title, model, accountViewModel, nav) + } +} + +@Composable +fun MultiUserErrorMessageDialogInner( + title: String, + model: UserBasedErrorMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + AlertDialog( + onDismissRequest = model::clearErrors, + title = { Text(title) }, + text = { + val errorState by model.errors.collectAsStateWithLifecycle(emptyList()) + LazyColumn { + itemsIndexed(errorState) { index, it -> + ErrorRow(it, accountViewModel, nav) + if (index < errorState.size - 1) { + HorizontalDivider(thickness = DividerThickness) + } + } + } + }, + confirmButton = { + Button( + onClick = model::clearErrors, + contentPadding = PaddingValues(horizontal = Size16dp), + ) { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringRes(R.string.error_dialog_button_ok)) + } + }, + ) +} + +@Composable +fun ErrorRow( + errorState: UserBasedErrorMessage, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), + 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)) + } + }, + ) { + val descriptor = + it.info?.bestName()?.let { + stringRes(R.string.error_dialog_talk_to_user_name, it) + } ?: stringRes(R.string.error_dialog_talk_to_user) + + Icon( + painter = painterResource(R.drawable.ic_dm), + contentDescription = descriptor, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + + Row(Modifier.weight(1f)) { + SelectionContainer { + Text(errorState.error) + } + } + } +} 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 8b824a3ff..e50cb178f 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 @@ -25,6 +25,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -46,6 +47,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.distinctUntilChanged @@ -86,12 +88,14 @@ import com.vitorpamplona.amethyst.ui.note.types.EmptyState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay +import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack @@ -100,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight +import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent @@ -154,9 +159,11 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.DraftEvent @@ -169,12 +176,14 @@ import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.events.NIP90StatusEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -314,9 +323,9 @@ fun AcceptableNote( ) } is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) - is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, false, accountViewModel) - is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, false, accountViewModel) - is VideoEvent -> JustVideoDisplay(baseNote, false, false, accountViewModel) + is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, ContentScale.FillWidth, accountViewModel) + is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, ContentScale.FillWidth, accountViewModel) + is VideoEvent -> JustVideoDisplay(baseNote, false, ContentScale.FillWidth, accountViewModel) else -> LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> CheckNewAndRenderNote( @@ -599,8 +608,8 @@ private fun RenderNoteRow( ) { when (val noteEvent = baseNote.event) { is AppDefinitionEvent -> RenderAppDefinition(baseNote, accountViewModel, nav) - is AudioTrackEvent -> RenderAudioTrack(baseNote, false, accountViewModel, nav) - is AudioHeaderEvent -> RenderAudioHeader(baseNote, false, accountViewModel, nav) + is AudioTrackEvent -> RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav) + is AudioHeaderEvent -> RenderAudioHeader(baseNote, ContentScale.FillWidth, accountViewModel, nav) is DraftEvent -> RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav) is ReactionEvent -> RenderReaction(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) is RepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) @@ -664,6 +673,18 @@ private fun RenderNoteRow( nav, ) } + is ChatMessageEncryptedFileHeaderEvent -> { + RenderChatMessageEncryptedFile( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + editState, + accountViewModel, + nav, + ) + } is ClassifiedsEvent -> { RenderClassifieds( noteEvent, @@ -683,6 +704,19 @@ private fun RenderNoteRow( nav, ) } + is CommentEvent -> { + RenderTextEvent( + baseNote, + makeItShort, + canPreview, + quotesLeft, + unPackReply, + backgroundColor, + editState, + accountViewModel, + nav, + ) + } is NIP90ContentDiscoveryResponseEvent -> RenderNIP90ContentDiscoveryResponse( baseNote, @@ -713,10 +747,12 @@ private fun RenderNoteRow( nav, ) } - is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, false, accountViewModel) - is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav) - is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav) - is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, false, accountViewModel) + is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) + is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) + is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) + is PictureEvent -> PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav) + + is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) is CommunityPostApprovalEvent -> { RenderPostApproval( baseNote, @@ -780,6 +816,17 @@ private fun RenderNoteRow( nav, ) + is InteractiveStoryBaseEvent -> + RenderInteractiveStory( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + else -> { RenderTextEvent( baseNote, 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 feff10462..fb6940ec7 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 @@ -160,40 +160,6 @@ fun LongPressToQuickAction( } } -@Composable -fun LongPressToQuickActionGallery( - baseNote: Note, - accountViewModel: AccountViewModel, - content: @Composable (() -> Unit) -> Unit, -) { - val popupExpanded = remember { mutableStateOf(false) } - - content { popupExpanded.value = true } - - if (popupExpanded.value) { - if (baseNote.author == accountViewModel.account.userProfile()) { - NoteQuickActionMenuGallery( - note = baseNote, - onDismiss = { popupExpanded.value = false }, - accountViewModel = accountViewModel, - nav = EmptyNav, - ) - } - } -} - -@Composable -fun NoteQuickActionMenuGallery( - note: Note, - onDismiss: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - DeleteFromGalleryDialog(note, accountViewModel) { - onDismiss() - } -} - @Composable fun NoteQuickActionMenu( note: Note, @@ -657,25 +623,6 @@ fun NoteQuickActionItem( } } -@Composable -fun DeleteFromGalleryDialog( - note: Note, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, -) { - QuickActionAlertDialogOneButton( - title = stringRes(R.string.quick_action_request_deletion_gallery_title), - textContent = stringRes(R.string.quick_action_request_deletion_gallery_alert_body), - buttonIcon = Icons.Default.Delete, - buttonText = stringRes(R.string.quick_action_delete_dialog_btn), - onClickDoOnce = { - accountViewModel.removefromMediaGallery(note) - onDismiss() - }, - onDismiss = onDismiss, - ) -} - @Composable fun DeleteAlertDialog( note: Note, 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 653872c22..4058efc27 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 @@ -73,6 +73,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.EmptyNav @@ -559,7 +560,7 @@ fun ZapVote( poolOption.option, "", context, - onError = { title, message -> + onError = { title, message, user -> zappingProgress = 0f showErrorMessageDialog = StringToastMsg(title, message) }, @@ -583,7 +584,7 @@ fun ZapVote( zappingProgress = 0f }, onChangeAmount = { wantsToZap = false }, - onError = { title, message -> + onError = { title, message, user -> showErrorMessageDialog = StringToastMsg(title, message) zappingProgress = 0f }, @@ -604,7 +605,7 @@ fun ZapVote( showErrorMessageDialog = StringToastMsg( stringRes(context, R.string.error_dialog_zap_error), - it, + it.error, ) } }, @@ -613,7 +614,7 @@ fun ZapVote( showErrorMessageDialog = StringToastMsg( stringRes(context, R.string.error_dialog_zap_error), - it, + it.error, ) } }, @@ -681,7 +682,7 @@ fun FilteredZapAmountChoicePopup( pollOption: Int, onDismiss: () -> Unit, onChangeAmount: () -> Unit, - onError: (title: String, text: String) -> Unit, + onError: (title: String, text: String, toUser: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, ) { 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 680fcd8b5..f4e1193de 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 @@ -38,7 +38,6 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -48,7 +47,6 @@ 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button @@ -100,9 +98,12 @@ import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ClickableBox @@ -110,9 +111,7 @@ 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.note.types.RenderReaction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -129,6 +128,7 @@ import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightWithPadding import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiserWithPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing +import com.vitorpamplona.amethyst.ui.theme.Size14dp import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size18dp import com.vitorpamplona.amethyst.ui.theme.Size19Modifier @@ -992,13 +992,13 @@ fun ZapReaction( accountViewModel: AccountViewModel, iconSize: Dp = Size20dp, iconSizeModifier: Modifier = Size20Modifier, - animationSize: Dp = 14.dp, + animationSize: Dp = Size14dp, nav: INav, ) { var wantsToZap by remember { mutableStateOf(false) } var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) } - var showErrorMessageDialog by remember { mutableStateOf>(emptyList()) } + val errorViewModel: UserBasedErrorMessageViewModel = viewModel() var wantsToPay by remember(baseNote) { mutableStateOf>( @@ -1030,10 +1030,10 @@ fun ZapReaction( wantsToZap = true } }, - onError = { _, message -> + onError = { _, message, user -> scope.launch { zappingProgress = 0f - showErrorMessageDialog = showErrorMessageDialog + message + errorViewModel.add(message, user) } }, onPayViaIntent = { wantsToPay = it }, @@ -1059,10 +1059,10 @@ fun ZapReaction( wantsToChangeZapAmount = true } }, - onError = { _, message -> + onError = { _, message, user -> scope.launch { zappingProgress = 0f - showErrorMessageDialog = showErrorMessageDialog + message + errorViewModel.add(message, user) } }, onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, @@ -1070,22 +1070,12 @@ fun ZapReaction( ) } - if (showErrorMessageDialog.isNotEmpty()) { - val msg = showErrorMessageDialog.joinToString("\n") - ErrorMessageDialog( - title = stringRes(id = R.string.error_dialog_zap_error), - textContent = msg, - onClickStartMessage = { - baseNote.author?.let { - scope.launch(Dispatchers.IO) { - val route = routeToMessage(it, msg, accountViewModel) - nav.nav(route) - } - } - }, - onDismiss = { showErrorMessageDialog = emptyList() }, - ) - } + MultiUserErrorMessageDialog( + title = stringRes(id = R.string.error_dialog_zap_error), + model = errorViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) if (wantsToChangeZapAmount) { UpdateZapAmountDialog( @@ -1103,12 +1093,12 @@ fun ZapReaction( wantsToPay = persistentListOf() scope.launch { zappingProgress = 0f - showErrorMessageDialog = showErrorMessageDialog + it + errorViewModel.add(it) } }, justShowError = { scope.launch { - showErrorMessageDialog = showErrorMessageDialog + it + errorViewModel.add(it) } }, ) @@ -1117,10 +1107,10 @@ fun ZapReaction( if (wantsToSetCustomZap) { ZapCustomDialog( onClose = { wantsToSetCustomZap = false }, - onError = { _, message -> + onError = { _, message, user -> scope.launch { zappingProgress = 0f - showErrorMessageDialog = showErrorMessageDialog + message + errorViewModel.add(message, user) } }, onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, @@ -1170,7 +1160,7 @@ fun zapClick( context: Context, onZappingProgress: (Float) -> Unit, onMultipleChoices: () -> Unit, - onError: (String, String) -> Unit, + onError: (String, String, User?) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, ) { if (baseNote.isDraft()) { @@ -1566,7 +1556,7 @@ fun ZapAmountChoicePopup( popupYOffset: Dp, onDismiss: () -> Unit, onChangeAmount: () -> Unit, - onError: (title: String, text: String) -> Unit, + onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, ) { @@ -1585,7 +1575,7 @@ fun ZapAmountChoicePopup( popupYOffset: Dp, onDismiss: () -> Unit, onChangeAmount: () -> Unit, - onError: (title: String, text: String) -> Unit, + onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, ) { @@ -1602,7 +1592,7 @@ fun ZapAmountChoicePopup( popupYOffset: Dp, visibilityState: MutableTransitionState, onChangeAmount: () -> Unit, - onError: (title: String, text: String) -> Unit, + onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 4cc691cfe..6d9939c0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -29,8 +29,8 @@ import java.text.SimpleDateFormat import java.util.Locale var locale = Locale.getDefault() -var yearFormatter = SimpleDateFormat(" • MMM dd, yyyy", locale) -var monthFormatter = SimpleDateFormat(" • MMM dd", locale) +var yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale) +var monthFormatter = SimpleDateFormat("MMM dd", locale) fun timeAgo( time: Long?, @@ -46,20 +46,20 @@ fun timeAgo( if (locale != Locale.getDefault()) { locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(" • MMM dd, yyyy", locale) - monthFormatter = SimpleDateFormat(" • MMM dd", locale) + yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat("MMM dd", locale) } - yearFormatter.format(time * 1000) + " • " + yearFormatter.format(time * 1000) } else if (timeDifference > TimeUtils.ONE_MONTH) { // Dec 12 if (locale != Locale.getDefault()) { locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(" • MMM dd, yyyy", locale) - monthFormatter = SimpleDateFormat(" • MMM dd", locale) + yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat("MMM dd", locale) } - monthFormatter.format(time * 1000) + " • " + monthFormatter.format(time * 1000) } else if (timeDifference > TimeUtils.ONE_DAY) { // 2 days " • " + (timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d) @@ -112,6 +112,40 @@ fun timeAgoNoDot( } } +fun dateFormatter( + time: Long?, + never: String, + today: String, +): String { + if (time == null) return " " + if (time == 0L) return " $never" + + val timeDifference = TimeUtils.now() - time + + return if (timeDifference > TimeUtils.ONE_YEAR) { + // Dec 12, 2022 + + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat("MMM dd", locale) + } + + yearFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_DAY) { + // Dec 12 + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat("MMM dd", locale) + } + + monthFormatter.format(time * 1000) + } else { + today + } +} + fun timeAgoShort( mills: Long?, stringForNow: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index eb1ddab57..2cccf7099 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -25,27 +25,19 @@ import android.content.Intent import android.net.Uri 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Done -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -56,7 +48,6 @@ 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.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -72,7 +63,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner @@ -82,7 +75,6 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ZeroPadding @@ -112,7 +104,7 @@ class ZapOptionstViewModel : ViewModel() { @Composable fun ZapCustomDialog( onClose: () -> Unit, - onError: (title: String, text: String) -> Unit, + onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, accountViewModel: AccountViewModel, @@ -290,66 +282,21 @@ fun ZapButton( } } -@Composable -fun ErrorMessageDialog( - title: String, - textContent: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onClickStartMessage: (() -> Unit)? = null, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { SelectionContainer { Text(textContent) } }, - confirmButton = { - Row( - modifier = Modifier.padding(vertical = 8.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - onClickStartMessage?.let { - TextButton(onClick = onClickStartMessage) { - Icon( - painter = painterResource(R.drawable.ic_dm), - contentDescription = null, - ) - Spacer(StdHorzSpacer) - Text(stringRes(R.string.error_dialog_talk_to_user)) - } - } - Button( - onClick = onDismiss, - colors = buttonColors, - contentPadding = PaddingValues(horizontal = Size16dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Done, - contentDescription = null, - ) - Spacer(StdHorzSpacer) - Text(stringRes(R.string.error_dialog_button_ok)) - } - } - } - }, - ) -} - @Composable fun PayViaIntentDialog( payingInvoices: ImmutableList, accountViewModel: AccountViewModel, onClose: () -> Unit, - onError: (String) -> Unit, - justShowError: (String) -> Unit, + onError: (UserBasedErrorMessage) -> Unit, + justShowError: (UserBasedErrorMessage) -> Unit, ) { val context = LocalContext.current if (payingInvoices.size == 1) { - payViaIntent(payingInvoices.first().invoice, context, onClose, onError) + val payable = payingInvoices.first() + payViaIntent(payable.invoice, context, onClose) { + onError(UserBasedErrorMessage(it, payable.user)) + } } else { Dialog( onDismissRequest = onClose, @@ -357,8 +304,10 @@ fun PayViaIntentDialog( DialogProperties( dismissOnClickOutside = false, usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, ), ) { + SetDialogToEdgeToEdge() Surface { Column(modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState())) { Row( @@ -370,15 +319,15 @@ fun PayViaIntentDialog( Spacer(modifier = DoubleVertSpacer) - payingInvoices.forEachIndexed { index, it -> + payingInvoices.forEachIndexed { index, payable -> val paid = remember { mutableStateOf(false) } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp), ) { - if (it.user != null) { - BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel) + if (payable.user != null) { + BaseUserPicture(payable.user, Size55dp, accountViewModel = accountViewModel) } else { DisplayBlankAuthor(size = Size55dp, accountViewModel = accountViewModel) } @@ -386,8 +335,8 @@ fun PayViaIntentDialog( Spacer(modifier = DoubleHorzSpacer) Column(modifier = Modifier.weight(1f)) { - if (it.user != null) { - UsernameDisplay(it.user, accountViewModel = accountViewModel) + if (payable.user != null) { + UsernameDisplay(payable.user, accountViewModel = accountViewModel) } else { Text( text = stringRes(id = R.string.wallet_number, index + 1), @@ -399,7 +348,7 @@ fun PayViaIntentDialog( } Row { Text( - text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()), + text = showAmount((payable.amountMilliSats / 1000.0f).toBigDecimal()), maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold, @@ -419,7 +368,9 @@ fun PayViaIntentDialog( Spacer(modifier = DoubleHorzSpacer) PayButton(isActive = !paid.value) { - payViaIntent(it.invoice, context, { paid.value = true }, justShowError) + payViaIntent(payable.invoice, context, { paid.value = true }) { + justShowError(UserBasedErrorMessage(it, null)) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index 73df0737a..3829d9109 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.clickable import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -34,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun DisplayEditStatus(editState: EditState) { - ClickableText( + Text( text = buildAnnotatedString { if (editState.showingVersion.value == editState.originalVersionId()) { @@ -45,15 +46,12 @@ fun DisplayEditStatus(editState: EditState) { append(stringRes(id = R.string.edited_number, editState.versionId())) } }, - onClick = { - editState.nextModification() - }, style = LocalTextStyle.current.copy( color = MaterialTheme.colorScheme.placeholderText, fontWeight = FontWeight.Bold, ), maxLines = 1, - modifier = HalfStartPadding, + modifier = HalfStartPadding.clickable { editState.nextModification() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index accda0ccd..37f3fd944 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -107,7 +107,7 @@ fun NoteDropDownMenu( var reportDialogShowing by remember { mutableStateOf(false) } var state by remember { - mutableStateOf( + mutableStateOf( DropDownParams( isFollowingAuthor = false, isPrivateBookmarkNote = false, @@ -169,6 +169,16 @@ fun NoteDropDownMenu( }, ) HorizontalDivider(thickness = DividerThickness) + } else { + DropdownMenuItem( + text = { Text(stringRes(R.string.unfollow)) }, + onClick = { + val author = note.author ?: return@DropdownMenuItem + accountViewModel.unfollow(author) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) } DropdownMenuItem( text = { Text(stringRes(R.string.copy_text)) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt index c16749b89..e48cf9721 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt @@ -58,10 +58,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ClickableText @@ -69,11 +71,11 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.CloseIcon -import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog +import com.vitorpamplona.amethyst.ui.note.MultiUserErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog +import com.vitorpamplona.amethyst.ui.note.UserBasedErrorMessageViewModel import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZappedIcon @@ -291,7 +293,7 @@ fun ZapDonationButton( nav: INav, ) { var wantsToZap by remember { mutableStateOf?>(null) } - var showErrorMessageDialog by remember { mutableStateOf(null) } + val errorViewModel: UserBasedErrorMessageViewModel = viewModel() var wantsToPay by remember(baseNote) { mutableStateOf>( @@ -315,10 +317,10 @@ fun ZapDonationButton( scope.launch { zappingProgress = progress } }, onMultipleChoices = { options -> wantsToZap = options.toImmutableList() }, - onError = { _, message -> + onError = { _, message, toUser -> scope.launch { zappingProgress = 0f - showErrorMessageDialog = message + errorViewModel.add(message, toUser) } }, onPayViaIntent = { wantsToPay = it }, @@ -339,10 +341,10 @@ fun ZapDonationButton( onChangeAmount = { wantsToZap = null }, - onError = { _, message -> + onError = { _, message, user -> scope.launch { zappingProgress = 0f - showErrorMessageDialog = message + errorViewModel.add(message, user) } }, onProgress = { @@ -352,21 +354,12 @@ fun ZapDonationButton( ) } - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = stringRes(id = R.string.error_dialog_zap_error), - textContent = showErrorMessageDialog ?: "", - onClickStartMessage = { - baseNote.author?.let { - scope.launch(Dispatchers.IO) { - val route = routeToMessage(it, showErrorMessageDialog, accountViewModel) - nav.nav(route) - } - } - }, - onDismiss = { showErrorMessageDialog = null }, - ) - } + MultiUserErrorMessageDialog( + title = stringRes(id = R.string.error_dialog_zap_error), + model = errorViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) if (wantsToPay.isNotEmpty()) { PayViaIntentDialog( @@ -377,12 +370,12 @@ fun ZapDonationButton( wantsToPay = persistentListOf() scope.launch { zappingProgress = 0f - showErrorMessageDialog = it + errorViewModel.add(it) } }, justShowError = { scope.launch { - showErrorMessageDialog = it + errorViewModel.add(it) } }, ) @@ -444,7 +437,7 @@ fun customZapClick( context: Context, onZappingProgress: (Float) -> Unit, onMultipleChoices: (List) -> Unit, - onError: (String, String) -> Unit, + onError: (String, String, User?) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, ) { if (baseNote.isDraft()) { 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 f5f91715f..ec7a3a63c 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 @@ -147,10 +147,10 @@ fun RenderAppDefinition( var zoomImageDialogOpen by remember { mutableStateOf(false) } Box(Modifier.size(100.dp)) { - it.picture?.let { + it.picture?.let { picture -> AsyncImage( - model = it, - contentDescription = null, + model = picture, + contentDescription = it.name, contentScale = ContentScale.FillWidth, modifier = Modifier @@ -163,7 +163,7 @@ fun RenderAppDefinition( .background(MaterialTheme.colorScheme.background) .combinedClickable( onClick = { zoomImageDialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(it)) }, + onLongClick = { clipboardManager.setText(AnnotatedString(picture)) }, ), ) } 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 d439a5285..a76052a01 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 @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -61,20 +62,20 @@ import java.util.Locale @Composable fun RenderAudioTrack( note: Note, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, nav: INav, ) { val noteEvent = note.event as? AudioTrackEvent ?: return - AudioTrackHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav) + AudioTrackHeader(noteEvent, note, contentScale, accountViewModel, nav) } @Composable fun AudioTrackHeader( noteEvent: AudioTrackEvent, note: Note, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, nav: INav, ) { @@ -148,7 +149,7 @@ fun AudioTrackHeader( thumbUri = cover, authorName = note.author?.toBestDisplayName(), roundedCorner = true, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, nostrUriCallback = "nostr:${note.toNEvent()}", accountViewModel = accountViewModel, ) @@ -159,7 +160,7 @@ fun AudioTrackHeader( title = noteEvent.subject(), authorName = note.author?.toBestDisplayName(), roundedCorner = true, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, accountViewModel = accountViewModel, ) } @@ -172,20 +173,20 @@ fun AudioTrackHeader( @Composable fun RenderAudioHeader( note: Note, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, nav: INav, ) { val noteEvent = note.event as? AudioHeaderEvent ?: return - AudioHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav) + AudioHeader(noteEvent, note, contentScale, accountViewModel, nav) } @Composable fun AudioHeader( noteEvent: AudioHeaderEvent, note: Note, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, nav: INav, ) { @@ -210,7 +211,7 @@ fun AudioHeader( title = noteEvent.subject(), authorName = note.author?.toBestDisplayName(), roundedCorner = true, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, accountViewModel = accountViewModel, nostrUriCallback = note.toNostrUri(), ) 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 ea240d7e5..83e8e2759 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 @@ -20,23 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import android.graphics.Bitmap import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable 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.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -45,81 +37,37 @@ 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.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.get import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter -import coil3.request.SuccessResult -import coil3.toBitmap import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.BadgeDefinitionEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun BadgeDisplay(baseNote: Note) { val observingNote by baseNote.live().metadata.observeAsState() val badgeData = observingNote?.note?.event as? BadgeDefinitionEvent ?: return - val image = badgeData.image() - val name = badgeData.name() - val description = badgeData.description() - - val background = MaterialTheme.colorScheme.background - var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) } - var imageResult by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = imageResult) { - launch(Dispatchers.IO) { - imageResult?.let { - val backgroundColor = - it.image - .toBitmap(200, 200) - .copy(Bitmap.Config.ARGB_8888, false)[0, 199] - val colorFromImage = Color(backgroundColor) - val textBackground = - if (colorFromImage.luminance() > 0.5) { - lightColorScheme().onBackground - } else { - darkColorScheme().onBackground - } - - launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) } - } - } - } - RenderBadge( - image, - name, - backgroundFromImage.first, - backgroundFromImage.second, - description, - ) { - if (imageResult == null) { - imageResult = it.result - } - } + badgeData.image(), + badgeData.name(), + MaterialTheme.colorScheme.background, + MaterialTheme.colorScheme.onBackground, + badgeData.description(), + ) } @Preview @@ -132,10 +80,9 @@ private fun RenderBadgePreview() { image = "http://test.com", name = "Name", backgroundForRow = background, - backgroundFromImage = Color.LightGray, + textColor = Color.LightGray, description = "This badge is awarded to the dedicated individuals who actively contributed by writing events to the relay during the crucial testing phase leading up to the first beta release of Grain.", - ) { - } + ) } } @@ -144,21 +91,11 @@ private fun RenderBadge( image: String?, name: String?, backgroundForRow: Color, - backgroundFromImage: Color, + textColor: Color, description: String?, - onSuccess: (AsyncImagePainter.State.Success) -> Unit, ) { Row( - modifier = - Modifier - .padding(10.dp) - .clip(shape = CutCornerShape(20, 20, 20, 20)) - .aspectRatio(0.8f) - .border( - 5.dp, - MaterialTheme.colorScheme.mediumImportanceLink, - CutCornerShape(20), - ).background(backgroundForRow), + modifier = Modifier.padding(vertical = 10.dp), ) { Column { image?.let { @@ -169,9 +106,8 @@ private fun RenderBadge( R.string.badge_award_image_for, name ?: "", ), - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth().background(backgroundForRow), contentScale = ContentScale.FillWidth, - onSuccess = onSuccess, ) } @@ -184,7 +120,7 @@ private fun RenderBadge( Modifier .fillMaxWidth() .padding(start = 10.dp, end = 10.dp), - color = backgroundFromImage, + color = textColor, ) } @@ -196,10 +132,8 @@ private fun RenderBadge( modifier = Modifier .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, + .padding(start = 20.dp, end = 20.dp), + color = textColor, ) } } @@ -225,19 +159,12 @@ fun RenderBadgeAward( FlowRow(modifier = Modifier.padding(top = 5.dp)) { awardees.take(100).forEach { user -> - Row( - modifier = - Modifier - .size(size = Size35dp) - .clickable { nav.nav("User/${user.pubkeyHex}") }, - verticalAlignment = Alignment.CenterVertically, - ) { - ClickableUserPicture( - baseUser = user, - accountViewModel = accountViewModel, - size = Size35dp, - ) - } + UserPicture( + user = user, + size = Size35dp, + accountViewModel = accountViewModel, + nav = nav, + ) } if (awardees.size > 100) { 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 new file mode 100644 index 000000000..7adc1a7de --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.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.note.types + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.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.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.crypto.nip17.AESGCM +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.events.ChatroomKeyable +import com.vitorpamplona.quartz.events.EmptyTagList +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun RenderChatMessageEncryptedFile( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + editState: State>, + accountViewModel: AccountViewModel, + nav: INav, +) { + val userRoom by + remember(note) { + derivedStateOf { + (note.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex) + } + } + + userRoom?.let { + if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) { + ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) { + routeFor(note, accountViewModel.userProfile())?.let { + nav.nav(it) + } + } + Spacer(modifier = StdVertSpacer) + } + } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + Box(modifier = HalfVertPadding) { + RenderEncryptedFile(note, backgroundColor, accountViewModel, nav) + } + } +} + +@Composable +fun RenderEncryptedFile( + note: Note, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? ChatMessageEncryptedFileHeaderEvent ?: return + + val algo = noteEvent.algo() + val key = noteEvent.key() + val nonce = noteEvent.nonce() + + if (algo == AESGCM.NAME && key != null && nonce != null) { + HttpClientManager.addCipherToCache(noteEvent.content, AESGCM(key, nonce)) + + val content by remember(noteEvent) { + val isImage = noteEvent.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content) + val mimeType = noteEvent.mimeType() + + mutableStateOf( + if (isImage) { + EncryptedMediaUrlImage( + url = noteEvent.content, + description = noteEvent.alt(), + hash = noteEvent.originalHash(), + blurhash = noteEvent.blurhash(), + dim = noteEvent.dimensions(), + uri = noteEvent.toNostrUri(), + mimeType = mimeType, + encryptionAlgo = algo, + encryptionKey = key, + encryptionNonce = nonce, + ) + } else { + EncryptedMediaUrlVideo( + url = noteEvent.content, + description = noteEvent.alt(), + hash = noteEvent.originalHash(), + blurhash = noteEvent.blurhash(), + dim = noteEvent.dimensions(), + uri = note.toNostrUri(), + authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, + encryptionAlgo = algo, + encryptionKey = key, + encryptionNonce = nonce, + ) + }, + ) + } + + ZoomableContentView( + content, + persistentListOf(content), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel, + ) + } else { + TranslatableRichTextViewer( + content = stringRes(id = R.string.could_not_decrypt_the_message), + canPreview = true, + quotesLeft = 0, + modifier = Modifier, + tags = EmptyTagList, + backgroundColor = backgroundBubbleColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } +} 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 30034f7cf..3dcc749af 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 @@ -137,7 +137,7 @@ public fun RenderEmojiPack( IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { AsyncImage( model = emoji.url, - contentDescription = null, + contentDescription = emoji.code, modifier = Size35Modifier, ) } @@ -148,7 +148,7 @@ public fun RenderEmojiPack( ) { AsyncImage( model = emoji.url, - contentDescription = null, + contentDescription = emoji.code, modifier = Size35Modifier, ) } 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 663f01d06..df65fde27 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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo @@ -38,7 +39,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent fun FileHeaderDisplay( note: Note, roundedCorner: Boolean, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, ) { val event = (note.event as? FileHeaderEvent) ?: return @@ -84,7 +85,7 @@ fun FileHeaderDisplay( ZoomableContentView( content = content, roundedCorner = roundedCorner, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, accountViewModel = accountViewModel, ) } 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 091424477..176d24d17 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 @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent @@ -43,7 +44,7 @@ import java.io.File fun FileStorageHeaderDisplay( baseNote: Note, roundedCorner: Boolean, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, ) { val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return @@ -51,7 +52,7 @@ fun FileStorageHeaderDisplay( LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote -> if (contentNote != null) { - ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, isFiniteHeight, accountViewModel) + ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, contentScale, accountViewModel) } } } @@ -61,7 +62,7 @@ private fun ObserverAndRenderNIP95( header: Note, content: Note, roundedCorner: Boolean, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, ) { val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return @@ -113,7 +114,7 @@ private fun ObserverAndRenderNIP95( ZoomableContentView( content = it, roundedCorner = roundedCorner, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, accountViewModel = accountViewModel, ) } 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 new file mode 100644 index 000000000..6d857a0af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +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.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent +import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent + +@Composable +fun RenderInteractiveStory( + baseNote: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val baseRootEvent = baseNote.event as? InteractiveStoryBaseEvent ?: return + val address = baseNote.address() ?: return + + // keep updating the root event with new versions + val note = baseNote.live().metadata.observeAsState() + 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 latestReadingNoteState = readingStateNote.live().metadata.observeAsState() + val readingState = latestReadingNoteState.value?.note?.event as? InteractiveStoryReadingStateEvent + + val currentScene = readingState?.currentScene() + + if (currentScene != null && currentScene != rootEvent.address()) { + LoadAddressableNote(currentScene, accountViewModel) { currentSceneBaseNote -> + val currentScene = currentSceneBaseNote?.live()?.metadata?.observeAsState() + val currentSceneEvent = currentScene?.value?.note?.event as? InteractiveStoryBaseEvent + + if (currentSceneEvent != null) { + RenderInteractiveStory( + section = currentSceneEvent, + onSelect = { + val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) + }, + onRestart = { + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) + }, + makeItShort = makeItShort, + canPreview = canPreview, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else { + RenderInteractiveStory( + section = rootEvent, + onSelect = { + val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) + }, + onRestart = { + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) + }, + makeItShort = makeItShort, + canPreview = canPreview, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun RenderInteractiveStory( + section: InteractiveStoryBaseEvent, + onSelect: (AddressableNote) -> Unit, + onRestart: () -> Unit, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + section.title()?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 5.dp, bottom = 10.dp), + ) { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + TranslatableRichTextViewer( + content = section.content, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = section.id, + callbackUri = null, + accountViewModel = accountViewModel, + nav = nav, + ) + + val options = section.options() + + if (options.isNotEmpty()) { + Column(Modifier.padding(top = 10.dp)) { + options.forEach { opt -> + LoadAddressableNote(opt.address, accountViewModel) { note -> + if (note != null) { + val optionState = note.live().metadata.observeAsState() + + OutlinedButton( + onClick = { onSelect(note) }, + ) { + Text(opt.option) + } + } + } + } + } + } else { + Column(Modifier.padding(top = 10.dp)) { + OutlinedButton( + onClick = onRestart, + ) { + Text("Restart") + } + } + } +} + +@Stable +class StoryReadingState { + private var sectionList = mutableMapOf() + private var sectionToShowId: HexKey? = null + + val sectionToShow: MutableState = mutableStateOf(null) + + fun readSection(note: Note) { + sectionList[note.idHex] = note + sectionToShowId = note.idHex + sectionToShow.value = note + } +} 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 673a0a246..8ac38dccc 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 @@ -40,6 +40,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -183,7 +184,7 @@ fun RenderLiveActivityEventInner( artworkUri = cover, authorName = baseNote.author?.toBestDisplayName(), roundedCorner = true, - isFiniteHeight = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, nostrUriCallback = "nostr:${baseNote.toNEvent()}", ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt index 4ed3b1107..613fc4e8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt index 5349c37b4..19fe270db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt new file mode 100644 index 000000000..28f7520e6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.PictureEvent +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun PictureDisplay( + note: Note, + roundedCorner: Boolean, + contentScale: ContentScale, + padding: PaddingValues, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? PictureEvent) ?: return + val uri = note.toNostrUri() + + val images by + remember(note) { + mutableStateOf( + event + .imetaTags() + .map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = uri, + mimeType = it.mimeType, + ) + }.toImmutableList(), + ) + } + + val first = images.firstOrNull() + + if (first != null) { + val title = event.title() + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column { + if (title != null) { + Text( + modifier = Modifier.padding(padding), + text = title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(StdVertSpacer) + } + + if (images.size == 1) { + ZoomableContentView( + content = images.first(), + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } else { + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + + TranslatableRichTextViewer( + content = event.content, + canPreview = false, + quotesLeft = 0, + modifier = Modifier.padding(padding), + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = uri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} 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 dea594279..431935d5e 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 @@ -45,9 +45,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView import com.vitorpamplona.amethyst.ui.components.ShowMoreButton import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.AddRelayButton import com.vitorpamplona.amethyst.ui.note.RemoveRelayButton import com.vitorpamplona.amethyst.ui.note.getGradient @@ -61,6 +61,8 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import java.net.URLEncoder +import java.nio.charset.StandardCharsets @Composable fun DisplayRelaySet( @@ -289,15 +291,13 @@ private fun RelayOptionsAction( } } - var wantsToAddRelay by remember { mutableStateOf("") } - - if (wantsToAddRelay.isNotEmpty()) { - AllRelayListView({ wantsToAddRelay = "" }, wantsToAddRelay, accountViewModel, nav = nav) - } - if (isCurrentlyOnTheUsersList) { - AddRelayButton { wantsToAddRelay = relay } + AddRelayButton { + nav.nav(Route.EditRelays.base + "?toAdd=" + URLEncoder.encode(relay, StandardCharsets.UTF_8.toString())) + } } else { - RemoveRelayButton { wantsToAddRelay = relay } + RemoveRelayButton { + nav.nav(Route.EditRelays.base + "?toAdd=" + URLEncoder.encode(relay, StandardCharsets.UTF_8.toString())) + } } } 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 55ad32bd2..e0a448a45 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 @@ -66,50 +66,47 @@ fun VideoDisplay( makeItShort: Boolean, canPreview: Boolean, backgroundColor: MutableState, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, nav: INav, ) { val event = (note.event as? VideoEvent) ?: return - val fullUrl = event.url() ?: return + val imeta = event.imetaTags().firstOrNull() ?: return val title = event.title() val summary = event.content.ifBlank { null }?.takeIf { title != it } - val image = event.thumb() ?: event.image() - val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") + val image = imeta.image.firstOrNull() + val isYouTube = imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } val content by remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() val description = event.content.ifBlank { null } ?: event.alt() - val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) + val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val uri = note.toNostrUri() - val mimeType = event.mimeType() mutableStateOf( if (isImage) { MediaUrlImage( - url = fullUrl, + url = imeta.url, description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, + hash = imeta.hash, + blurhash = imeta.blurhash, + dim = imeta.dimension, uri = uri, - mimeType = mimeType, + mimeType = imeta.mimeType, ) } else { MediaUrlVideo( - url = fullUrl, + url = imeta.url, description = description, - hash = hash, - dim = dimensions, + hash = imeta.hash, + dim = imeta.dimension, uri = uri, authorName = note.author?.toBestDisplayName(), - artworkUri = event.thumb() ?: event.image(), - mimeType = mimeType, + artworkUri = imeta.image.firstOrNull(), + mimeType = imeta.mimeType, + blurhash = imeta.blurhash, ) }, ) @@ -126,7 +123,7 @@ fun VideoDisplay( if (isYouTube) { val uri = LocalUriHandler.current Row( - modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } }, + modifier = Modifier.clickable { runCatching { uri.openUri(imeta.url) } }, ) { image?.let { AsyncImage( @@ -147,7 +144,7 @@ fun VideoDisplay( ZoomableContentView( content = content, roundedCorner = true, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, accountViewModel = accountViewModel, ) } 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 5cc2cdab3..545455b3b 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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo @@ -38,43 +39,38 @@ import com.vitorpamplona.quartz.events.VideoEvent fun JustVideoDisplay( note: Note, roundedCorner: Boolean, - isFiniteHeight: Boolean, + contentScale: ContentScale, accountViewModel: AccountViewModel, ) { val event = (note.event as? VideoEvent) ?: return - val fullUrl = event.url() ?: return + val imeta = event.imetaTags().getOrNull(0) ?: return val content by remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.content.ifEmpty { null } ?: event.alt() - val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) - val uri = note.toNostrUri() - val mimeType = event.mimeType() + val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() + val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) mutableStateOf( if (isImage) { MediaUrlImage( - url = fullUrl, + url = imeta.url, description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - mimeType = mimeType, + hash = imeta.hash, + blurhash = imeta.blurhash, + dim = imeta.dimension, + uri = note.toNostrUri(), + mimeType = imeta.mimeType, ) } else { MediaUrlVideo( - url = fullUrl, + url = imeta.url, description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, + hash = imeta.hash, + blurhash = imeta.blurhash, + dim = imeta.dimension, + uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), - mimeType = mimeType, + mimeType = imeta.mimeType, ) }, ) @@ -84,7 +80,7 @@ fun JustVideoDisplay( ZoomableContentView( content = content, roundedCorner = roundedCorner, - isFiniteHeight = isFiniteHeight, + contentScale = contentScale, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 7693e2d7c..b3ac53388 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -97,23 +97,7 @@ fun AccountScreen( ) { LoggedInPage( state.accountSettings, - accountStateViewModel, - sharedPreferencesViewModel, - ) - } - - DisposableEffect(key1 = accountState) { - onDispose { - state.currentViewModelStore.viewModelStore.clear() - } - } - } - is AccountState.LoggedInViewOnly -> { - CompositionLocalProvider( - LocalViewModelStoreOwner provides state.currentViewModelStore, - ) { - LoggedInPage( - state.accountSettings, + state.route, accountStateViewModel, sharedPreferencesViewModel, ) @@ -132,6 +116,7 @@ fun AccountScreen( @Composable fun LoggedInPage( accountSettings: AccountSettings, + route: String?, accountStateViewModel: AccountStateViewModel, sharedPreferencesViewModel: SharedPreferencesViewModel, ) { @@ -145,6 +130,8 @@ fun LoggedInPage( ), ) + accountViewModel.firstRoute = route + LaunchedEffect(key1 = accountViewModel) { accountViewModel.restartServices() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 105630729..e7d4f4746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.AccountSettings sealed class AccountState { @@ -27,14 +28,10 @@ sealed class AccountState { object LoggedOff : AccountState() - class LoggedInViewOnly( - val accountSettings: AccountSettings, - ) : AccountState() { - val currentViewModelStore = AccountCentricViewModelStore(accountSettings) - } - + @Stable class LoggedIn( val accountSettings: AccountSettings, + var route: String? = null, ) : AccountState() { val currentViewModelStore = AccountCentricViewModelStore(accountSettings) } 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 78f375bee..c1eb75801 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 @@ -27,7 +27,6 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.LocalPreferences.currentAccount import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.DefaultChannels import com.vitorpamplona.amethyst.model.DefaultDMRelayList @@ -36,7 +35,6 @@ import com.vitorpamplona.amethyst.model.DefaultSearchRelayList 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.Client import com.vitorpamplona.ammolite.relays.Constants import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair @@ -67,7 +65,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.regex.Pattern -val EMAIL_PATTERN = Pattern.compile(".+@.+\\.[a-z]+") +val EMAIL_PATTERN: Pattern = Pattern.compile(".+@.+\\.[a-z]+") @Stable class AccountStateViewModel : ViewModel() { @@ -81,10 +79,10 @@ class AccountStateViewModel : ViewModel() { viewModelScope.launch { tryLoginExistingAccount() } } - private suspend fun tryLoginExistingAccount() = + private suspend fun tryLoginExistingAccount(route: String? = null) = withContext(Dispatchers.IO) { LocalPreferences.loadCurrentAccountFromEncryptedStorage() - }?.let { startUI(it) } ?: run { requestLoginUI() } + }?.let { startUI(it, route) } ?: run { requestLoginUI() } private suspend fun requestLoginUI() { _accountContent.update { AccountState.LoggedOff } @@ -162,22 +160,20 @@ class AccountStateViewModel : ViewModel() { } @OptIn(FlowPreview::class) - suspend fun startUI(accountSettings: AccountSettings) = - withContext(Dispatchers.Main) { - if (accountSettings.isWriteable()) { - _accountContent.update { AccountState.LoggedIn(accountSettings) } - } else { - _accountContent.update { AccountState.LoggedInViewOnly(accountSettings) } - } + suspend fun startUI( + accountSettings: AccountSettings, + route: String? = null, + ) = withContext(Dispatchers.Main) { + _accountContent.update { AccountState.LoggedIn(accountSettings, route) } - collectorJob?.cancel() - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - accountSettings.saveable.debounce(1000).collect { - LocalPreferences.saveToEncryptedStorage(it.accountSettings) - } + collectorJob?.cancel() + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + accountSettings.saveable.debounce(1000).collect { + LocalPreferences.saveToEncryptedStorage(it.accountSettings) } - } + } + } private fun prepareLogoutOrSwitch() = when (val state = _accountContent.value) { @@ -186,11 +182,6 @@ class AccountStateViewModel : ViewModel() { state.currentViewModelStore.viewModelStore.clear() } - is AccountState.LoggedInViewOnly -> { - collectorJob?.cancel() - state.currentViewModelStore.viewModelStore.clear() - } - else -> {} } @@ -305,33 +296,50 @@ class AccountStateViewModel : ViewModel() { GlobalScope.launch(Dispatchers.IO) { delay(2000) // waits for the new user to connect to the new relays. - accountSettings.backupUserMetadata?.let { Client.send(it) } - accountSettings.backupContactList?.let { Client.send(it) } - accountSettings.backupNIP65RelayList?.let { Client.send(it) } - accountSettings.backupDMRelayList?.let { Client.send(it) } - accountSettings.backupSearchRelayList?.let { Client.send(it) } + accountSettings.backupUserMetadata?.let { Amethyst.instance.client.send(it) } + accountSettings.backupContactList?.let { Amethyst.instance.client.send(it) } + accountSettings.backupNIP65RelayList?.let { Amethyst.instance.client.send(it) } + accountSettings.backupDMRelayList?.let { Amethyst.instance.client.send(it) } + accountSettings.backupSearchRelayList?.let { Amethyst.instance.client.send(it) } } } } fun switchUser(accountInfo: AccountInfo) { viewModelScope.launch(Dispatchers.IO) { - prepareLogoutOrSwitch() - LocalPreferences.switchToAccount(accountInfo) - tryLoginExistingAccount() + switchUserSync(accountInfo) } } + suspend fun switchUserSync( + npub: String, + route: String, + ): Boolean { + if (npub != LocalPreferences.currentAccount()) { + val account = LocalPreferences.allSavedAccounts().firstOrNull { it.npub == npub } + if (account != null) { + switchUserSync(account, route) + return true + } + } + return false + } + + suspend fun switchUserSync( + accountInfo: AccountInfo, + route: String? = null, + ) { + prepareLogoutOrSwitch() + LocalPreferences.switchToAccount(accountInfo) + tryLoginExistingAccount(route) + } + fun currentAccount() = when (val state = _accountContent.value) { is AccountState.LoggedIn -> state.accountSettings.keyPair.pubKey .toNpub() - is AccountState.LoggedInViewOnly -> - state.accountSettings.keyPair.pubKey - .toNpub() - else -> null } 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 e6ce44535..b8108c3f5 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 @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen import android.util.Log +import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -69,6 +70,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch @@ -246,8 +248,8 @@ class NostrUserFollowSetFeedViewModel( @Stable class NostrNIP90ContentDiscoveryFeedViewModel( val account: Account, - val dvmkey: String, - val requestid: String, + dvmkey: String, + requestid: String, ) : FeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmkey, requestid)) { class Factory( val account: Account, @@ -286,7 +288,19 @@ abstract class LevelFeedViewModel( ) : FeedViewModel(localFilter) { var llState: LazyListState by mutableStateOf(LazyListState(0, 0)) - // val cachedLevels = mutableMapOf>() + val hasDragged = mutableStateOf(false) + + val selectedIDHex = + llState.interactionSource.interactions + .onEach { + if (it is DragInteraction.Start) { + hasDragged.value = true + } + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) @OptIn(ExperimentalCoroutinesApi::class) val levelCacheFlow: StateFlow> = 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 11f61d91b..ee5812705 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 @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DeletionEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent @@ -105,7 +106,7 @@ class FollowListState( unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)), ) - val defaultLists = persistentListOf(kind3Follow, globalFollow, aroundMe, muteListFollow) + val defaultLists = persistentListOf(kind3Follow, aroundMe, globalFollow, muteListFollow) fun getPeopleLists(): List = account @@ -354,6 +355,7 @@ val DEFAULT_FEED_KINDS = LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, WikiNoteEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ) val DEFAULT_COMMUNITY_FEEDS = @@ -367,4 +369,5 @@ val DEFAULT_COMMUNITY_FEEDS = PinListEvent.KIND, WikiNoteEvent.KIND, CommunityPostApprovalEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ) 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 027713819..ef32ca148 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 @@ -35,6 +35,7 @@ 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.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -71,6 +72,7 @@ import androidx.compose.ui.platform.LocalAutofill import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -92,10 +94,13 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.authenticate +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder 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.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.toHexKey @@ -123,8 +128,7 @@ fun DialogContentsPreview() { ThemeComparisonRow { DialogContents( mockAccountViewModel(), - {}, - ) + ) {} } } @@ -183,7 +187,15 @@ private fun DialogContents( Spacer(modifier = Modifier.height(20.dp)) - NSecCopyButton(accountViewModel) + Row { + Column { + NSecCopyButton(accountViewModel) + } + + Column { + QrCodeButton(accountViewModel) + } + } Spacer(modifier = Modifier.height(30.dp)) @@ -238,7 +250,7 @@ private fun DialogContents( }, keyboardOptions = KeyboardOptions( - autoCorrect = false, + autoCorrectEnabled = false, keyboardType = KeyboardType.Password, imeAction = ImeAction.Go, ), @@ -349,30 +361,38 @@ private fun EncryptNSecCopyButton( } } - OutlinedButton( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - authenticate( - title = stringRes(context, R.string.copy_my_secret_key), - context = context, - keyguardLauncher = keyguardLauncher, - onApproved = { encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) }, - onError = { title, message -> accountViewModel.toast(title, message) }, - ) - }, - shape = ButtonBorder, - contentPadding = ButtonPadding, - enabled = password.value.text.isNotBlank(), - ) { - Icon( - imageVector = Icons.Default.Key, - contentDescription = - stringRes(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), - modifier = Modifier.padding(end = 5.dp), - ) - Text( - stringRes(id = R.string.encrypt_and_copy_my_secret_key), - ) + Row { + Column { + OutlinedButton( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + authenticate( + title = stringRes(context, R.string.copy_my_secret_key), + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + }, + shape = ButtonBorder, + contentPadding = ButtonPadding, + enabled = password.value.text.isNotBlank(), + ) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = + stringRes(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), + modifier = Modifier.padding(end = 5.dp), + ) + Text( + stringRes(id = R.string.encrypt_and_copy_my_secret_key), + ) + } + } + + Column { + QrCodeButtonEncrypted(accountViewModel, password) + } } } @@ -448,3 +468,119 @@ private fun encryptCopyNSec( } } } + +@Composable +private fun QrCodeButtonBase( + accountViewModel: AccountViewModel, + isEnabled: Boolean = true, + contentDescription: Int, + onDialogShow: () -> String?, +) { + val context = LocalContext.current + + // store the dialog open or close state + var dialogOpen by remember { mutableStateOf(false) } + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + dialogOpen = true + } + } + + IconButton( + enabled = isEnabled, + onClick = { + authenticate( + title = stringRes(context, R.string.copy_my_secret_key), + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { dialogOpen = true }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + contentDescription = stringRes(id = contentDescription), + modifier = Modifier.size(24.dp), + tint = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.grayText, + ) + } + + if (dialogOpen) { + ShowKeyQRDialog( + onDialogShow(), + onClose = { dialogOpen = false }, + ) + } +} + +@Composable +private fun QrCodeButton(accountViewModel: AccountViewModel) { + QrCodeButtonBase( + accountViewModel = accountViewModel, + contentDescription = R.string.show_private_key_qr_code, + onDialogShow = { + accountViewModel.account.settings.keyPair.privKey + ?.toNsec() + }, + ) +} + +@Composable +private fun QrCodeButtonEncrypted( + accountViewModel: AccountViewModel, + password: MutableState, +) { + QrCodeButtonBase( + accountViewModel = accountViewModel, + isEnabled = password.value.text.isNotBlank(), + contentDescription = R.string.show_encrypted_private_key_qr_code, + onDialogShow = { + accountViewModel.account.settings.keyPair.privKey + ?.toHexKey() + ?.let { CryptoUtils.encryptNIP49(it, password.value.text) } + }, + ) +} + +@Composable +private fun ShowKeyQRDialog( + qrCode: String?, + onClose: () -> Unit, +) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(10.dp), + ) { + // Back button at the top + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + BackButton(onPress = onClose) + } + + // QR Code content + Column( + modifier = + Modifier + .fillMaxSize() + .padding(vertical = 10.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + QrCodeDrawer(qrCode ?: "error") + } + } + } + } +} 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 bdc3f23b7..b1e9b2ce6 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 @@ -36,6 +36,7 @@ import coil3.imageLoader import coil3.request.ImageRequest import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.collectSuccessfulOperations import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.model.Account @@ -73,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.Nip11RelayInformation import com.vitorpamplona.quartz.encoders.Nip19Bech32 @@ -87,6 +89,8 @@ import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent +import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent @@ -108,9 +112,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job -import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -121,12 +123,8 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.coroutines.resume @Immutable open class ToastMsg @@ -155,6 +153,8 @@ class AccountViewModel( Dao { val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope) + var firstRoute: String? = null + // TODO: contact lists are not notes yet // val kind3Relays: StateFlow = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey) @@ -506,6 +506,12 @@ class AccountViewModel( } } + class DecryptedInfo( + val zapRequest: Note, + val zapEvent: Note?, + val info: ZapAmountCommentNotification, + ) + fun decryptAmountMessageInGroup( zaps: ImmutableList, onNewState: (ImmutableList) -> Unit, @@ -524,17 +530,19 @@ class AccountViewModel( ) }.toMutableMap() - collectSuccessfulSigningOperations( - operationsInput = zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, + collectSuccessfulOperations( + items = zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, runRequestFor = { next, onReady -> checkNotInMainThread() - innerDecryptAmountMessage(next.request, next.response, onReady) + innerDecryptAmountMessage(next.request, next.response) { + onReady(DecryptedInfo(next.request, next.response, it)) + } }, ) { checkNotInMainThread() - it.forEach { decrypted -> initialResults[decrypted.key.request] = decrypted.value } + it.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } onNewState(initialResults.values.toImmutableList()) } @@ -628,13 +636,15 @@ class AccountViewModel( ) }.toMutableMap() - collectSuccessfulSigningOperations, ZapAmountCommentNotification>( - operationsInput = myList, + collectSuccessfulOperations, DecryptedInfo>( + items = myList, runRequestFor = { next, onReady -> - innerDecryptAmountMessage(next.first, next.second, onReady) + innerDecryptAmountMessage(next.first, next.second) { + onReady(DecryptedInfo(next.first, next.second, it)) + } }, ) { - it.forEach { decrypted -> initialResults[decrypted.key.first] = decrypted.value } + it.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } onNewState(initialResults.values.toImmutableList()) } @@ -693,7 +703,7 @@ class AccountViewModel( message: String, context: Context, showErrorIfNoLnAddress: Boolean = true, - onError: (String, String) -> Unit, + onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, @@ -759,7 +769,7 @@ class AccountViewModel( url: String, relay: String?, blurhash: String?, - dim: String?, + dim: Dimension?, hash: String?, mimeType: String?, ) { @@ -901,9 +911,7 @@ class AccountViewModel( } } - fun markDonatedInThisVersion() { - account.markDonatedInThisVersion() - } + fun markDonatedInThisVersion() = account.markDonatedInThisVersion() fun dontTranslateFrom() = account.settings.syncedSettings.languages.dontTranslateFrom @@ -1106,7 +1114,7 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateAddressableNote(key)) } } - suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? = LocalCache.getOrCreateAddressableNote(key) + suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote = LocalCache.getOrCreateAddressableNote(key) fun getOrCreateAddressableNote( key: ATag, @@ -1562,6 +1570,28 @@ class AccountViewModel( AdvertisedRelayListEvent.createAddressTag(user.pubkeyHex), ) + fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddressATag(account.signer.pubKey, dATag)) + + fun updateInteractiveStoryReadingState( + root: InteractiveStoryBaseEvent, + readingScene: InteractiveStoryBaseEvent, + ) { + viewModelScope.launch(Dispatchers.IO) { + val sceneNoteRelayHint = LocalCache.getOrCreateAddressableNote(readingScene.address()).relayHintUrl() + + val readingState = getInteractiveStoryReadingState(root.addressTag()) + val readingStateEvent = readingState.event as? InteractiveStoryReadingStateEvent + + if (readingStateEvent != null) { + account.updateInteractiveStoryReadingState(readingStateEvent, readingScene, sceneNoteRelayHint) + } else { + val rootNoteRelayHint = LocalCache.getOrCreateAddressableNote(root.address()).relayHintUrl() + + account.createInteractiveStoryReadingState(root, rootNoteRelayHint, readingScene, sceneNoteRelayHint) + } + } + } + fun sendSats( lnaddress: String, milliSats: Long, @@ -1606,6 +1636,8 @@ class AccountViewModel( } } + fun relayStatusFlow() = Amethyst.instance.client.relayStatusFlow() + val draftNoteCache = CachedDraftNotes(this) class CachedDraftNotes( @@ -1679,42 +1711,6 @@ class AccountViewModel( val nip19: Nip19Bech32.ParseReturn, ) -public suspend fun collectSuccessfulSigningOperations( - operationsInput: List, - runRequestFor: (T, (K) -> Unit) -> Unit, - output: MutableMap = mutableMapOf(), - onReady: suspend (MutableMap) -> Unit, -) { - if (operationsInput.isEmpty()) { - onReady(output) - return - } - - coroutineScope { - val jobs = - operationsInput.map { - async { - val result = - withTimeoutOrNull(10000) { - suspendCancellableCoroutine { continuation -> - runRequestFor(it) { result: K -> continuation.resume(result) } - } - } - if (result != null) { - output[it] = result - } - } - } - - // runs in parallel to avoid overcrowding Amber. - withTimeoutOrNull(15000) { - jobs.joinAll() - } - } - - onReady(output) -} - @Composable fun mockAccountViewModel(): AccountViewModel { val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt index f59f403d1..1800e7c77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt @@ -39,11 +39,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 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 5bb1eaed5..de986ffac 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 @@ -22,15 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.Manifest import android.content.Intent -import android.graphics.Bitmap import android.net.Uri -import android.os.Build import android.os.Parcelable -import android.util.Log -import android.util.Size import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.border @@ -60,7 +54,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.filled.LocationOff import androidx.compose.material.icons.filled.LocationOn @@ -91,6 +84,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf @@ -106,7 +100,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -128,25 +121,31 @@ import coil3.compose.AsyncImage import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.Nip96MediaServers +import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewPollOption import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog -import com.vitorpamplona.amethyst.ui.actions.ServerOption -import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation -import com.vitorpamplona.amethyst.ui.actions.getPhotoUri +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.components.BechLink 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.VideoView import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.navigation.Nav @@ -162,6 +161,7 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.MyTextField import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -181,10 +181,9 @@ 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.events.ClassifiedsEvent -import com.vitorpamplona.quartz.events.FileServersEvent import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay @@ -204,12 +203,14 @@ fun NewPostScreen( fork: Note? = null, version: Note? = null, draft: Note? = null, + enableGeolocation: Boolean = false, enableMessageInterface: Boolean = false, accountViewModel: AccountViewModel, nav: Nav, ) { val postViewModel: NewPostViewModel = viewModel() postViewModel.wantsDirectMessage = enableMessageInterface + postViewModel.wantsToAddGeoHash = enableGeolocation val context = LocalContext.current val activity = context.getActivity() @@ -241,7 +242,8 @@ fun NewPostScreen( postViewModel.updateMessage(TextFieldValue(it)) } attachment?.let { - postViewModel.selectImage(it) + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) } } } @@ -266,7 +268,8 @@ fun NewPostScreen( } (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { - postViewModel.selectImage(it) + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) } } } @@ -453,7 +456,7 @@ fun NewPostScreen( myUrlPreview, mimeType = null, roundedCorner = true, - isFiniteHeight = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) } else { @@ -504,22 +507,22 @@ fun NewPostScreen( } } - val url = postViewModel.contentToAddUrl - if (url != null) { + postViewModel.multiOrchestrator?.let { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { ImageVideoDescription( - url, + it, accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent, mediaQuality -> - postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) - if (!server.isNip95) { - accountViewModel.account.settings.changeDefaultFileServer(server.server) + postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) } }, - onCancel = { postViewModel.contentToAddUrl = null }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } }, accountViewModel = accountViewModel, ) @@ -603,7 +606,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { .height(50.dp), verticalAlignment = CenterVertically, ) { - UploadFromGallery( + SelectFromGallery( isUploading = postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, @@ -662,59 +665,6 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { } } -@OptIn(ExperimentalPermissionsApi::class) -@Composable -fun TakePictureButton(onPictureTaken: (Uri) -> Unit) { - var imageUri by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.TakePicture(), - ) { success -> - if (success) { - imageUri?.let { - onPictureTaken(it) - } - } - } - val context = LocalContext.current - val cameraPermissionState = - rememberPermissionState( - Manifest.permission.CAMERA, - onPermissionResult = { - if (it) { - scope.launch(Dispatchers.IO) { - imageUri = getPhotoUri(context) - imageUri?.let { uri -> launcher.launch(uri) } - } - } - }, - ) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { - if (cameraPermissionState.status.isGranted) { - scope.launch(Dispatchers.IO) { - imageUri = getPhotoUri(context) - imageUri?.let { uri -> launcher.launch(uri) } - } - } else { - cameraPermissionState.launchPermissionRequest() - } - }, - ) { - Icon( - imageVector = Icons.Default.CameraAlt, - contentDescription = stringRes(id = R.string.upload_image), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } - } -} - @Composable private fun PollField(postViewModel: NewPostViewModel) { val optionsList = postViewModel.pollOptions @@ -1285,6 +1235,10 @@ fun LocationAsHash(postViewModel: NewPostViewModel) { Manifest.permission.ACCESS_COARSE_LOCATION, ) + LaunchedEffect(locationPermissionState.status.isGranted) { + Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted) + } + if (locationPermissionState.status.isGranted) { Column( modifier = Modifier.fillMaxWidth(), @@ -1326,6 +1280,13 @@ fun LocationAsHash(postViewModel: NewPostViewModel) { color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(vertical = 10.dp), ) + + SettingsRow( + R.string.geohash_exclusive, + R.string.geohash_exclusive_explainer, + ) { + Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) + } } } else { LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } @@ -1334,9 +1295,28 @@ fun LocationAsHash(postViewModel: NewPostViewModel) { @Composable fun DisplayLocationObserver(postViewModel: NewPostViewModel) { - val location by postViewModel.locationFlow().collectAsStateWithLifecycle(null) + val location by postViewModel.locationFlow().collectAsStateWithLifecycle() - location?.let { DisplayLocationInTitle(geohash = it) } + when (val myLocation = location) { + is LocationState.LocationResult.Success -> { + DisplayLocationInTitle(geohash = myLocation.geoHash.toString()) + } + + LocationState.LocationResult.LackPermission -> { + Text( + text = stringRes(R.string.lack_location_permissions), + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + LocationState.LocationResult.Loading -> { + Text( + text = stringRes(R.string.loading_location), + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + } } @Composable @@ -1687,62 +1667,33 @@ fun CreateButton( @Composable fun ImageVideoDescription( - uri: Uri, - defaultServer: Nip96MediaServers.ServerName, - onAdd: (String, ServerOption, Boolean, Int) -> Unit, + uris: MultiOrchestrator, + defaultServer: ServerName, + onAdd: (String, ServerName, Boolean, Int) -> Unit, + onDelete: (SelectedMediaProcessing) -> Unit, onCancel: () -> Unit, onError: (Int) -> Unit, accountViewModel: AccountViewModel, ) { - val resolver = LocalContext.current.contentResolver - val mediaType = resolver.getType(uri) ?: "" + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val isImage = mediaType.startsWith("image") - val isVideo = mediaType.startsWith("video") - - val listOfNip96ServersNote = - accountViewModel.account - .getFileServersNote() - .live() - .metadata - .observeAsState() - - val fileServers = - ( - (listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map { - ServerOption( - Nip96MediaServers.ServerName( - it, - it, - ), - false, - ) - } ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } - ) + - listOf( - ServerOption( - Nip96MediaServers.ServerName( - "NIP95", - stringRes(id = R.string.upload_server_relays_nip95), - ), - true, - ), - ) + val fileServers by accountViewModel.account.liveServerList.collectAsState() val fileServerOptions = - remember { - fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) + } + }.toImmutableList() } var selectedServer by remember { mutableStateOf( - ServerOption( - fileServers - .firstOrNull { it.server == defaultServer } - ?.server - ?: fileServers[0].server, - false, - ), + fileServers.firstOrNull { it == defaultServer } ?: fileServers[0], ) } var message by remember { mutableStateOf("") } @@ -1776,19 +1727,23 @@ fun ImageVideoDescription( .fillMaxWidth() .padding(bottom = 10.dp), ) { - Text( - text = - stringRes( - if (isImage) { - R.string.content_description_add_image + val text = + if (uris.size() == 1) { + if (uris.first().media.isImage() == true) { + R.string.content_description_add_image + } else { + if (uris.first().media.isVideo() == true) { + R.string.content_description_add_video } else { - if (isVideo) { - R.string.content_description_add_video - } else { - R.string.content_description_add_document - } - }, - ), + R.string.content_description_add_document + } + } + } else { + R.string.content_description_add_media + } + + Text( + text = stringRes(text), fontSize = 20.sp, fontWeight = FontWeight.W500, modifier = @@ -1819,48 +1774,7 @@ fun ImageVideoDescription( .padding(bottom = 10.dp) .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), ) { - if (mediaType.startsWith("image")) { - AsyncImage( - model = uri.toString(), - contentDescription = uri.toString(), - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .padding(top = 4.dp) - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) - } else if ( - mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - ) { - var bitmap by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = uri) { - launch(Dispatchers.IO) { - try { - bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) - } catch (e: Exception) { - if (e is CancellationException) throw e - onError(R.string.unable_to_load_thumbnail) - Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) - } - } - } - - bitmap?.let { - Image( - bitmap = it.asImageBitmap(), - contentDescription = "some useful description", - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .padding(top = 4.dp) - .fillMaxWidth(), - ) - } - } else { - VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, mimeType = mediaType, accountViewModel = accountViewModel) - } + ShowImageUploadGallery(uris, onDelete, accountViewModel) } Row( @@ -1871,10 +1785,9 @@ fun ImageVideoDescription( label = stringRes(id = R.string.file_server), placeholder = fileServers - .firstOrNull { it.server == defaultServer } - ?.server + .firstOrNull { it == defaultServer } ?.name - ?: fileServers[0].server.name, + ?: fileServers[0].name, options = fileServerOptions, onSelect = { selectedServer = fileServers[it] }, modifier = @@ -2023,10 +1936,11 @@ fun SettingSwitchItem( onValueChange = onCheckedChange, ), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), ) { Column( - modifier = Modifier.weight(1.0f), - verticalArrangement = Arrangement.spacedBy(Size5dp), + modifier = Modifier.weight(2.0f), + verticalArrangement = Arrangement.spacedBy(3.dp), ) { Text( text = stringRes(id = title), @@ -2042,10 +1956,12 @@ fun SettingSwitchItem( ) } - Switch( - checked = checked, - onCheckedChange = null, - enabled = enabled, - ) + Column(Modifier.weight(1f), horizontalAlignment = Alignment.End) { + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + ) + } } } 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/chatrooms/ChannelScreen.kt index 5d6683576..a6e85e04a 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/chatrooms/ChannelScreen.kt @@ -110,15 +110,14 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrChannelDataSource +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.ui.actions.NewChannelView import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel -import com.vitorpamplona.amethyst.ui.actions.ServerOption -import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation -import com.vitorpamplona.amethyst.ui.components.CompressorQuality +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -342,7 +341,7 @@ fun ChannelScreen( } // LAST ROW - EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) { + EditFieldRow(newPostModel, accountViewModel = accountViewModel) { scope.launch(Dispatchers.IO) { innerSendPost(replyTo, channel, newPostModel, accountViewModel, null) newPostModel.message = TextFieldValue("") @@ -396,7 +395,7 @@ private suspend fun innerSendPost( tagger.run() val urls = findURLs(tagger.message) - val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } + val usedAttachments = newPostModel.iMetaAttachments.filter { it.url in urls.toSet() } if (channel is PublicChatChannel) { accountViewModel.account.sendChannelMessage( @@ -404,8 +403,9 @@ private suspend fun innerSendPost( toChannel = channel.idHex, replyTo = tagger.eTags, mentions = tagger.pTags, + directMentions = tagger.directMentions, wantsToMarkAsSensitive = false, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = draftTag, ) } else if (channel is LiveActivitiesChannel) { @@ -415,7 +415,7 @@ private suspend fun innerSendPost( replyTo = tagger.eTags, mentions = tagger.pTags, wantsToMarkAsSensitive = false, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = draftTag, ) } @@ -470,7 +470,6 @@ fun DisplayReplyingToNote( @Composable fun EditFieldRow( channelScreenModel: NewPostViewModel, - isPrivate: Boolean, accountViewModel: AccountViewModel, onSendNewMessage: () -> Unit, ) { @@ -507,18 +506,18 @@ fun EditFieldRow( } }, leadingIcon = { - UploadFromGallery( + SelectFromGallery( isUploading = channelScreenModel.isUploadingImage, tint = MaterialTheme.colorScheme.placeholderText, modifier = EditFieldLeadingIconModifier, ) { + channelScreenModel.selectImage(it) channelScreenModel.upload( - galleryUri = it, alt = null, sensitiveContent = false, // Use MEDIUM quality - mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM), - server = ServerOption(accountViewModel.account.settings.defaultFileServer, false), + mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM), + server = accountViewModel.account.settings.defaultFileServer, onError = accountViewModel::toast, context = context, ) @@ -794,7 +793,7 @@ fun ShowVideoStreaming( ZoomableContentView( content = zoomableUrlVideo, roundedCorner = false, - isFiniteHeight = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) } 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/chatrooms/ChatroomFeedView.kt index 729c2c63e..ee25d798e 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/chatrooms/ChatroomFeedView.kt @@ -36,6 +36,7 @@ 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 import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -44,12 +45,16 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.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.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.events.DraftEvent @Composable @@ -145,7 +150,7 @@ fun ChatroomFeedLoaded( reverseLayout = true, state = listState, ) { - itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> + itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item -> val noteEvent = item.event if (avoidDraft == null || noteEvent !is DraftEvent || noteEvent.dTag() != avoidDraft) { ChatroomMessageCompose( @@ -156,35 +161,57 @@ fun ChatroomFeedLoaded( onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, ) + + NewDateSubject(items.list.getOrNull(index + 1), item) } - NewSubject(item) } } } @Composable -fun NewSubject(note: Note) { +fun NewDateSubject( + previous: Note?, + note: Note, +) { + if (previous == null) return + + val never = stringRes(R.string.never) + val today = stringRes(R.string.today) + + val prevDate = remember(previous) { dateFormatter(previous.event?.createdAt(), never, today) } + val date = remember(note) { dateFormatter(note.event?.createdAt(), never, today) } + val subject = remember(note) { note.event?.subject() } - if (subject != null) { - NewSubject(newSubject = subject) + if (prevDate != date) { + if (subject != null) { + ChatDivisor("$date - $subject") + } else { + ChatDivisor(date) + } + } else { + if (subject != null) { + ChatDivisor(subject) + } } } @Composable -fun NewSubject(newSubject: String) { - Row(verticalAlignment = Alignment.CenterVertically) { +fun ChatDivisor(info: String) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = StdPadding) { HorizontalDivider( modifier = Modifier.weight(1f), + thickness = DividerThickness, ) Text( - text = newSubject, + 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/ChatroomListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListScreen.kt index 666936411..785138e7b 100644 --- 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 @@ -217,21 +217,23 @@ fun ChatroomListTwoPane( } }, second = { - twoPaneNav.innerNav.value?.let { - if (it.route == "Room") { - Chatroom( - roomId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } + 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, - ) + if (it.route == "Channel") { + Channel( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } }, 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/chatrooms/ChatroomMessageCompose.kt index 9dc270ab1..0f4f00342 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/chatrooms/ChatroomMessageCompose.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.note.WatchUserFollows import com.vitorpamplona.amethyst.ui.note.ZapReaction 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 @@ -95,17 +96,19 @@ 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.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent -import com.vitorpamplona.quartz.events.ChatMessageEvent +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists @@ -174,7 +177,7 @@ fun NormalChatNote( false // never shows the user's pictures } else if (noteEvent is PrivateDmEvent) { false // one-on-one, never shows it. - } else if (noteEvent is ChatMessageEvent) { + } else if (noteEvent is ChatroomKeyable) { // only shows in a group chat. noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1 } else { @@ -274,7 +277,7 @@ fun ChatBubbleLayout( inner: @Composable (MutableState) -> Unit, ) { val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink - val otherColors = MaterialTheme.colorScheme.subtleBorder + val otherColors = MaterialTheme.colorScheme.chatBackground val defaultBackground = MaterialTheme.colorScheme.background val backgroundBubbleColor = @@ -581,6 +584,13 @@ private fun NoteRow( accountViewModel, nav, ) + is ChatMessageEncryptedFileHeaderEvent -> + RenderEncryptedFile( + note, + backgroundBubbleColor, + accountViewModel, + nav, + ) else -> RenderRegularTextNote( note, @@ -631,16 +641,9 @@ private fun RenderDraftEvent( } } -@Composable -private fun ConstrainedStatusRow( - firstColumn: @Composable () -> Unit, - secondColumn: @Composable () -> Unit, -) { -} - @Composable fun IncognitoBadge(baseNote: Note) { - if (baseNote.event is ChatMessageEvent) { + if (baseNote.event is NIP17Group) { Icon( painter = painterResource(id = R.drawable.incognito), null, 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 index 37d8c1025..b700227f9 100644 --- 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 @@ -85,13 +85,13 @@ 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.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel -import com.vitorpamplona.amethyst.ui.actions.ServerOption -import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation -import com.vitorpamplona.amethyst.ui.components.CompressorQuality -import com.vitorpamplona.amethyst.ui.components.MediaCompressor +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 @@ -122,9 +122,10 @@ 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.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatroomKey +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.findURLs +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers @@ -483,18 +484,34 @@ fun ChatroomScreen( } // LAST ROW - PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) { - scope.launch(Dispatchers.IO) { - innerSendPost(newPostModel, room, replyTo, accountViewModel, null) + PrivateMessageEditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = { + scope.launch(Dispatchers.IO) { + innerSendPost(newPostModel, room, replyTo, accountViewModel, null) - accountViewModel.deleteDraft(newPostModel.draftTag) + accountViewModel.deleteDraft(newPostModel.draftTag) - newPostModel.message = TextFieldValue("") + newPostModel.message = TextFieldValue("") - replyTo.value = null - feedViewModel.sendToTop() - } - } + replyTo.value = null + feedViewModel.sendToTop() + } + }, + onSendNewMedia = { + newPostModel.selectImage(it) + newPostModel.uploadAsSeparatePrivateEvent( + toUsers = room.users, + alt = null, + sensitiveContent = false, + mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM), + server = accountViewModel.account.settings.defaultFileServer, + onError = accountViewModel::toast, + context = context, + ) + }, + ) } } @@ -506,16 +523,16 @@ private fun innerSendPost( dTag: String?, ) { val urls = findURLs(newPostModel.message.text) - val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } + val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() } - if (newPostModel.nip17 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { + 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, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = dTag, ) } else { @@ -525,7 +542,7 @@ private fun innerSendPost( replyingTo = replyTo.value, mentions = null, wantsToMarkAsSensitive = false, - nip94attachments = usedAttachments, + imetas = usedAttachments, draftTag = dTag, ) } @@ -534,9 +551,9 @@ private fun innerSendPost( @Composable fun PrivateMessageEditFieldRow( channelScreenModel: NewPostViewModel, - isPrivate: Boolean, accountViewModel: AccountViewModel, onSendNewMessage: () -> Unit, + onSendNewMedia: (ImmutableList) -> Unit, ) { Column( modifier = EditFieldModifier, @@ -574,26 +591,15 @@ fun PrivateMessageEditFieldRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 6.dp), ) { - UploadFromGallery( + SelectFromGallery( isUploading = channelScreenModel.isUploadingImage, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier .size(30.dp) .padding(start = 2.dp), - ) { - channelScreenModel.upload( - galleryUri = it, - alt = null, - sensitiveContent = false, - // use MEDIUM quality - mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM), - isPrivate = isPrivate, - server = ServerOption(accountViewModel.account.settings.defaultFileServer, false), - onError = accountViewModel::toast, - context = context, - ) - } + onImageChosen = onSendNewMedia, + ) var wantsToActivateNIP17 by remember { mutableStateOf(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt index 5f095bc2a..8546e965b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt @@ -26,8 +26,6 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource 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 327f1e977..c286fbac6 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 @@ -198,7 +198,6 @@ fun DiscoverScreen( } @Composable -@OptIn(ExperimentalFoundationApi::class) private fun DiscoverPages( pagerState: PagerState, tabs: ImmutableList, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt index 8be33a133..17707e6f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt @@ -210,7 +210,6 @@ private fun DraftFeedLoaded( .fillMaxWidth() .animateContentSize(), onStartToEnd = { accountViewModel.delete(item) }, - onEndToStart = { accountViewModel.delete(item) }, ) { NoteCompose( item, 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 232af4089..8ca18ab14 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 @@ -69,12 +69,12 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.DVMCard -import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog +import com.vitorpamplona.amethyst.ui.note.MultiUserErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog +import com.vitorpamplona.amethyst.ui.note.UserBasedErrorMessageViewModel import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup import com.vitorpamplona.amethyst.ui.note.ZapIcon @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.quartz.encoders.LnInvoiceUtil import com.vitorpamplona.quartz.events.AppDefinitionEvent +import com.vitorpamplona.quartz.events.AppMetadata import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.events.NIP90StatusEvent import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse @@ -203,11 +204,13 @@ fun ObserverContentDiscoveryResponse( } val latestResponse by resultFlow.collectAsStateWithLifecycle() + val myResponse = latestResponse - if (latestResponse != null) { + if (myResponse != null) { PrepareViewContentDiscoveryModels( noteAuthor, dvmRequestId.idHex, + myResponse, onRefresh, accountViewModel, nav, @@ -235,6 +238,7 @@ fun ObserverDvmStatusResponse( } val latestStatus by statusFlow.collectAsStateWithLifecycle() + // TODO: Make a good splash screen with loading animation for this DVM. if (latestStatus != null) { // TODO: Make a good splash screen with loading animation for this DVM. @@ -251,6 +255,7 @@ fun ObserverDvmStatusResponse( fun PrepareViewContentDiscoveryModels( dvm: User, dvmRequestId: String, + latestResponse: NIP90ContentDiscoveryResponseEvent, onRefresh: () -> Unit, accountViewModel: AccountViewModel, nav: INav, @@ -261,7 +266,7 @@ fun PrepareViewContentDiscoveryModels( factory = NostrNIP90ContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmkey = dvm.pubkeyHex, requestid = dvmRequestId), ) - LaunchedEffect(key1 = dvmRequestId) { + LaunchedEffect(key1 = dvmRequestId, latestResponse.id) { resultFeedViewModel.invalidateData() } @@ -434,7 +439,7 @@ fun ZapDVMButton( val noteAuthor = baseNote.author ?: return var wantsToZap by remember { mutableStateOf?>(null) } - var showErrorMessageDialog by remember { mutableStateOf(null) } + val errorViewModel: UserBasedErrorMessageViewModel = viewModel() var wantsToPay by remember(baseNote) { mutableStateOf>( @@ -461,10 +466,10 @@ fun ZapDVMButton( scope.launch { zappingProgress = progress } }, onMultipleChoices = { options -> wantsToZap = options }, - onError = { _, message -> + onError = { _, message, toUser -> scope.launch { zappingProgress = 0f - showErrorMessageDialog = message + errorViewModel.add(message, toUser) } }, onPayViaIntent = { wantsToPay = it }, @@ -485,10 +490,10 @@ fun ZapDVMButton( onChangeAmount = { wantsToZap = null }, - onError = { _, message -> + onError = { _, message, user -> scope.launch { zappingProgress = 0f - showErrorMessageDialog = message + errorViewModel.add(message, user) } }, onProgress = { @@ -498,21 +503,12 @@ fun ZapDVMButton( ) } - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = stringRes(id = R.string.error_dialog_zap_error), - textContent = showErrorMessageDialog ?: "", - onClickStartMessage = { - baseNote.author?.let { - scope.launch(Dispatchers.IO) { - val route = routeToMessage(it, showErrorMessageDialog, accountViewModel) - nav.nav(route) - } - } - }, - onDismiss = { showErrorMessageDialog = null }, - ) - } + MultiUserErrorMessageDialog( + title = stringRes(id = R.string.error_dialog_zap_error), + model = errorViewModel, + accountViewModel, + nav, + ) if (wantsToPay.isNotEmpty()) { PayViaIntentDialog( @@ -523,12 +519,12 @@ fun ZapDVMButton( wantsToPay = persistentListOf() scope.launch { zappingProgress = 0f - showErrorMessageDialog = it + errorViewModel.add(it) } }, justShowError = { scope.launch { - showErrorMessageDialog = it + errorViewModel.add(it) } }, ) @@ -627,6 +623,28 @@ fun FeedEmptyWithStatus( } } +fun convertAppMetadataToCard(metadata: AppMetadata?): DVMCard { + if (metadata == null) { + return DVMCard( + name = "", + description = "", + cover = null, + amount = "", + personalized = false, + ) + } + + return with(metadata) { + DVMCard( + name = this.name ?: "", + description = this.about ?: "", + cover = this.profilePicture()?.ifBlank { null }, + amount = this.amount ?: "", + personalized = this.personalized ?: false, + ) + } +} + @Composable fun observeAppDefinition(appDefinitionNote: Note): DVMCard { val noteEvent = @@ -634,6 +652,8 @@ fun observeAppDefinition(appDefinitionNote: Note): DVMCard { name = "", description = "", cover = null, + amount = "", + personalized = false, ) val card by @@ -641,20 +661,10 @@ fun observeAppDefinition(appDefinitionNote: Note): DVMCard { .live() .metadata .map { - val noteEvent = it.note.event as? AppDefinitionEvent - - DVMCard( - name = noteEvent?.appMetaData()?.name ?: "", - description = noteEvent?.appMetaData()?.about ?: "", - cover = noteEvent?.appMetaData()?.image?.ifBlank { null }, - ) + convertAppMetadataToCard((it.note.event as? AppDefinitionEvent)?.appMetaData()) }.distinctUntilChanged() .observeAsState( - DVMCard( - name = noteEvent.appMetaData()?.name ?: "", - description = noteEvent.appMetaData()?.about ?: "", - cover = noteEvent.appMetaData()?.image?.ifBlank { null }, - ), + convertAppMetadataToCard(noteEvent.appMetaData()), ) return card diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 94af2c47b..dc1c2ef18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -50,6 +50,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -191,7 +192,11 @@ private fun HomePages( } }, floatingButton = { - NewNoteButton(accountViewModel, nav) + val list = + accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + + NewNoteButton(nav, list.value == AROUND_ME) }, accountViewModel = accountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt index afd99f15e..570e3dbfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt @@ -26,8 +26,6 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource @@ -35,18 +33,17 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable fun NewNoteButton( - accountViewModel: AccountViewModel, nav: INav, + enableGeolocation: Boolean = false, ) { FloatingActionButton( onClick = { - val route = buildNewPostRoute() + val route = buildNewPostRoute(enableGeolocation = enableGeolocation) nav.nav(route) }, modifier = Size55Modifier, 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 02600d693..b08832eb1 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 @@ -41,9 +41,9 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent -import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.LnZapEvent +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent @@ -285,7 +285,7 @@ class CardFeedContentState( it.event !is GenericRepostEvent && it.event !is LnZapEvent }.map { - if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) { + if (it.event is PrivateDmEvent || it.event is NIP17Group) { MessageSetCard(it) } else if (it.event is BadgeAwardEvent) { BadgeCard(it) 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 02fbf05cb..2fa9ebae1 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 @@ -22,7 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn @@ -30,16 +33,19 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -53,8 +59,10 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose import com.vitorpamplona.amethyst.ui.note.elements.ZapTheDevsCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @Composable fun RefreshableCardView( @@ -108,7 +116,7 @@ fun RenderCardFeed( ) { state -> when (state) { is CardFeedState.Empty -> { - FeedEmpty(feedContent::invalidateData) + NotificationFeedEmpty(feedContent::invalidateData) } is CardFeedState.FeedError -> { FeedError(state.errorMessage, feedContent::invalidateData) @@ -129,6 +137,19 @@ fun RenderCardFeed( } } +@Composable +fun NotificationFeedEmpty(onRefresh: () -> Unit) { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringRes(R.string.notification_feed_is_empty)) + Spacer(modifier = StdVertSpacer) + OutlinedButton(onClick = onRefresh) { Text(text = stringRes(R.string.refresh)) } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun FeedLoaded( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileGallery.kt deleted file mode 100644 index 1d87e67f0..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileGallery.kt +++ /dev/null @@ -1,401 +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.profile - -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Alignment.Companion.BottomStart -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map -import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent -import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage -import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.GalleryContentView -import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty -import com.vitorpamplona.amethyst.ui.feeds.FeedError -import com.vitorpamplona.amethyst.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport -import com.vitorpamplona.amethyst.ui.note.ClickableNote -import com.vitorpamplona.amethyst.ui.note.LongPressToQuickActionGallery -import com.vitorpamplona.amethyst.ui.note.WatchAuthor -import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent -import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor -import com.vitorpamplona.amethyst.ui.note.elements.BannerImage -import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.HalfPadding -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent - -@Composable -fun RenderGalleryFeed( - viewModel: FeedViewModel, - routeForLastRead: String?, - listState: LazyGridState, - accountViewModel: AccountViewModel, - nav: INav, -) { - val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle() - CrossfadeIfEnabled( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - label = "RenderDiscoverFeed", - accountViewModel = accountViewModel, - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is FeedState.Loaded -> { - GalleryFeedLoaded( - state, - routeForLastRead, - listState, - accountViewModel, - nav, - ) - } - is FeedState.Loading -> { - LoadingFeed() - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun GalleryFeedLoaded( - loaded: FeedState.Loaded, - routeForLastRead: String?, - listState: LazyGridState, - accountViewModel: AccountViewModel, - nav: INav, -) { - val items by loaded.feed.collectAsStateWithLifecycle() - - LazyVerticalGrid( - columns = GridCells.Fixed(3), - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> - Row(Modifier.fillMaxWidth().animateItemPlacement()) { - GalleryCardCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - modifier = Modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - HorizontalDivider( - thickness = DividerThickness, - ) - } - } -} - -@Composable -fun GalleryCardCompose( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - isHiddenFeed: Boolean = false, - accountViewModel: AccountViewModel, - nav: INav, -) { - WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, shortPreview = true) { - CheckHiddenFeedWatchBlockAndReport( - note = baseNote, - modifier = modifier, - ignoreAllBlocksAndReports = isHiddenFeed, - showHiddenWarning = false, - accountViewModel = accountViewModel, - nav = nav, - ) { canPreview -> - - val galleryEvent = (baseNote.event as? ProfileGalleryEntryEvent) ?: return@CheckHiddenFeedWatchBlockAndReport - - galleryEvent.url()?.let { image -> - val sourceEvent = galleryEvent.fromEvent() - - if (sourceEvent != null) { - LoadNote(baseNoteHex = sourceEvent, accountViewModel = accountViewModel) { sourceNote -> - if (sourceNote != null) { - ClickableGalleryCard( - galleryNote = baseNote, - baseNote = sourceNote, - image = image, - modifier = modifier, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - GalleryCard( - galleryNote = baseNote, - image = image, - modifier = modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } else { - GalleryCard( - galleryNote = baseNote, - image = image, - modifier = modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - } -} - -@Composable -fun ClickableGalleryCard( - galleryNote: Note, - baseNote: Note, - image: String, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - // baseNote.event?.let { Text(text = it.pubKey()) } - LongPressToQuickActionGallery(baseNote = galleryNote, accountViewModel = accountViewModel) { showPopup -> - val backgroundColor = - calculateBackgroundColor( - createdAt = baseNote.createdAt(), - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - ) - - ClickableNote( - baseNote = baseNote, - backgroundColor = backgroundColor, - modifier = modifier, - accountViewModel = accountViewModel, - showPopup = showPopup, - nav = nav, - ) { - InnerGalleryCardBox(galleryNote, image, accountViewModel, nav) - } - } -} - -@Composable -fun GalleryCard( - galleryNote: Note, - image: String, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: INav, -) { - LongPressToQuickActionGallery(baseNote = galleryNote, accountViewModel = accountViewModel) { showPopup -> - Column(modifier = modifier) { - InnerGalleryCardBox(galleryNote, image, accountViewModel, nav) - } - } -} - -@Composable -fun InnerGalleryCardBox( - baseNote: Note, - image: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column(HalfPadding) { - SensitivityWarning( - note = baseNote, - accountViewModel = accountViewModel, - ) { - RenderGalleryThumb(baseNote, image, accountViewModel, nav) - } - } -} - -@Immutable -data class GalleryThumb( - val id: String?, - val image: String?, - val title: String?, -) - -@Composable -fun RenderGalleryThumb( - baseNote: Note, - image: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - val card by - baseNote - .live() - .metadata - .map { - GalleryThumb( - id = "", - image = image, - title = "", - // noteEvent?.title(), - ) - }.distinctUntilChanged() - .observeAsState( - GalleryThumb( - id = "", - image = image, - title = "", - ), - ) - - InnerRenderGalleryThumb(card, baseNote, accountViewModel) -} - -@Preview -@Composable -fun RenderGalleryThumbPreview() { - val accountViewModel = mockAccountViewModel() - - Surface(Modifier.size(200.dp)) { - InnerRenderGalleryThumb( - card = - GalleryThumb( - id = "", - image = null, - title = "Like New", - ), - note = Note("hex"), - accountViewModel = accountViewModel, - ) - } -} - -@Composable -fun InnerRenderGalleryThumb( - card: GalleryThumb, - note: Note, - accountViewModel: AccountViewModel, -) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f), - contentAlignment = BottomStart, - ) { - card.image?.let { - var blurHash = (note.event as ProfileGalleryEntryEvent).blurhash() - var description = (note.event as ProfileGalleryEntryEvent).content - // var hash = (note.event as ProfileGalleryEntryEvent).hash() - var dimensions = (note.event as ProfileGalleryEntryEvent).dimensions() - var mimeType = (note.event as ProfileGalleryEntryEvent).mimeType() - var content: BaseMediaContent? = null - - if (isVideoUrl(it)) { - content = - MediaUrlVideo( - url = it, - description = description, - hash = null, - blurhash = blurHash, - dim = dimensions, - uri = null, - mimeType = mimeType, - ) - } else { - content = - MediaUrlImage( - url = it, - description = description, - hash = null, // We don't want to show the hash banner here - blurhash = blurHash, - dim = dimensions, - uri = null, - mimeType = mimeType, - ) - } - - GalleryContentView( - content = content, - roundedCorner = false, - isFiniteHeight = false, - accountViewModel = accountViewModel, - ) - } - // } - ?: run { DisplayGalleryAuthorBanner(note) } - } -} - -@Composable -fun DisplayGalleryAuthorBanner(note: Note) { - WatchAuthor(note) { - BannerImage( - it, - Modifier - .fillMaxSize() - .clip(QuoteBorder), - ) - } -} 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 6ee47c2ba..c1d1a49ce 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 @@ -153,7 +153,6 @@ 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.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeToMessage @@ -179,6 +178,7 @@ 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.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -1286,6 +1286,14 @@ private fun DrawAdditionalInfo( fontSize = 25.sp, ) Spacer(StdHorzSpacer) + user.info?.pronouns?.let { + Text( + text = "($it)", + modifier = Modifier, + ) + Spacer(StdHorzSpacer) + } + DrawPlayName(it) } } @@ -1562,13 +1570,17 @@ private fun WatchApp( 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) { - val newAppLogo = - (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.picture?.ifBlank { null } - if (newAppLogo != appLogo) { - appLogo = newAppLogo + (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 + } } } } @@ -1583,7 +1595,7 @@ private fun WatchApp( ) { AsyncImage( model = appLogo, - contentDescription = null, + contentDescription = appName, modifier = remember { Modifier @@ -1827,17 +1839,13 @@ fun TabNotesNewThreads( nav: INav, ) { Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) - } + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) } } @@ -1848,21 +1856,16 @@ fun TabNotesConversations( nav: INav, ) { Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) - } + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun TabGallery( feedViewModel: NostrUserProfileGalleryFeedViewModel, @@ -1871,67 +1874,18 @@ fun TabGallery( ) { LaunchedEffect(Unit) { feedViewModel.invalidateData() } - // Column(Modifier.fillMaxHeight()) { - - RefresheableBox(feedViewModel, true) { + Column(Modifier.fillMaxHeight()) { SaveableGridFeedState(feedViewModel, scrollStateKey = ScrollStateKeys.PROFILE_GALLERY) { listState -> RenderGalleryFeed( feedViewModel, - null, listState, accountViewModel = accountViewModel, nav = nav, ) } } - - // } } -/*@Composable -fun Gallery( - baseUser: User, - feedViewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - WatchFollowChanges(baseUser, feedViewModel) - - Column(Modifier.fillMaxHeight()) { - Column { - baseUser.latestGalleryList?.let { - // val note2 = getOrCreateAddressableNoteInternal(aTag) - val note = LocalCache.getOrCreateAddressableNote(it.address()) - note.event = it - var notes = listOf() - for (tag in note.event?.tags()!!) { - if (tag.size > 2) { - if (tag[0] == "g") { - // TODO get the node by id on main thread. LoadNote does nothing. - val thumb = - GalleryThumb( - baseNote = note, - id = tag[2], - // TODO use the original note once it's loaded baseNote = basenote, - image = tag[1], - title = null, - ) - notes = notes + thumb - // } - } - } - ProfileGallery( - baseNotes = notes, - modifier = Modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - } -} */ - @Composable fun TabFollowedTags( baseUser: User, @@ -1998,9 +1952,7 @@ fun TabFollows( WatchFollowChanges(baseUser, feedViewModel) Column(Modifier.fillMaxHeight()) { - Column { - RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) - } + RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) } } @@ -2014,9 +1966,7 @@ fun TabFollowers( WatchFollowerChanges(baseUser, feedViewModel) Column(Modifier.fillMaxHeight()) { - Column { - RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) - } + RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) } } @@ -2050,7 +2000,7 @@ fun TabReceivedZaps( WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel) Column(Modifier.fillMaxHeight()) { - Column { LnZapFeedView(zapFeedViewModel, accountViewModel, nav) } + LnZapFeedView(zapFeedViewModel, accountViewModel, nav) } } @@ -2132,11 +2082,7 @@ fun TabRelays( } Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, nav = nav) - } + RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, 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/RelayFeedView.kt index 60614719d..eedeb0a03 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/RelayFeedView.kt @@ -26,17 +26,16 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider 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.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.RelayCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import java.net.URLEncoder +import java.nio.charset.StandardCharsets @Composable fun RelayFeedView( @@ -47,12 +46,6 @@ fun RelayFeedView( ) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - var wantsToAddRelay by remember { mutableStateOf("") } - - if (wantsToAddRelay.isNotEmpty()) { - AllRelayListView({ wantsToAddRelay = "" }, wantsToAddRelay, accountViewModel, nav = nav) - } - RefresheableBox(viewModel, enablePullRefresh) { val listState = rememberLazyListState() @@ -64,8 +57,12 @@ fun RelayFeedView( RelayCompose( item, accountViewModel = accountViewModel, - onAddRelay = { wantsToAddRelay = item.url }, - onRemoveRelay = { wantsToAddRelay = item.url }, + onAddRelay = { + nav.nav(Route.EditRelays.base + "?toAdd=" + URLEncoder.encode(item.url, StandardCharsets.UTF_8.toString())) + }, + onRemoveRelay = { + nav.nav(Route.EditRelays.base + "?toAdd=" + URLEncoder.encode(item.url, StandardCharsets.UTF_8.toString())) + }, ) HorizontalDivider( thickness = DividerThickness, 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 new file mode 100644 index 000000000..6756ed43d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.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.screen.loggedIn.profile.gallery + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport +import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.GenericRepostEvent +import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.events.RepostEvent + +@Composable +fun GalleryCardCompose( + baseNote: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, shortPreview = true) { + CheckHiddenFeedWatchBlockAndReport( + note = baseNote, + modifier = modifier, + ignoreAllBlocksAndReports = false, + showHiddenWarning = true, + accountViewModel = accountViewModel, + nav = nav, + ) { canPreview -> + val galleryEvent = baseNote.event + + val redirectToEventId = + if (galleryEvent is ProfileGalleryEntryEvent) { + galleryEvent.fromEvent() + } else { + null + } + + if (redirectToEventId != null) { + LoadNote(baseNoteHex = redirectToEventId, accountViewModel = accountViewModel) { sourceNote -> + if (sourceNote != null) { + RedirectableGalleryCard( + galleryNote = baseNote, + sourceNote = sourceNote, + modifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + RedirectableGalleryCard( + galleryNote = baseNote, + sourceNote = baseNote, + modifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else { + RedirectableGalleryCard( + galleryNote = baseNote, + sourceNote = baseNote, + modifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +fun RedirectableGalleryCard( + galleryNote: Note, + sourceNote: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + QuickActionGallery(baseNote = galleryNote, accountViewModel = accountViewModel) { showPopup -> + ClickableNote( + baseNote = sourceNote, + modifier = modifier, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) { + if (sourceNote != galleryNote) { + // preloads target note + val loadedSourceEvent by sourceNote.live().hasEvent.observeAsState(sourceNote.event != null) + } + + SensitivityWarning( + note = galleryNote, + accountViewModel = accountViewModel, + ) { + GalleryThumbnail(galleryNote, accountViewModel, nav) + } + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun ClickableNote( + baseNote: Note, + modifier: Modifier, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: INav, + content: @Composable () -> Unit, +) { + val updatedModifier = + remember(baseNote, modifier) { + modifier + .combinedClickable( + onClick = { + val redirectToNote = + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull() ?: baseNote + } else { + baseNote + } + routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav.nav(it) } + }, + onLongClick = showPopup, + ) + } + + Column(modifier = updatedModifier) { content() } +} 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 new file mode 100644 index 000000000..a149d01e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -0,0 +1,319 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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 android.content.Context +import androidx.annotation.OptIn +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +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.viewinterop.AndroidView +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash +import com.vitorpamplona.amethyst.ui.components.DisplayUrlWithLoadingSymbol +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.navigation.INav +import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon +import com.vitorpamplona.amethyst.ui.note.WatchAuthor +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size75dp +import com.vitorpamplona.quartz.events.PictureEvent +import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.events.VideoEvent + +@Composable +fun GalleryThumbnail( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = noteState?.note?.event ?: return + + val content = + if (noteEvent is ProfileGalleryEntryEvent) { + val url = noteEvent.url() + if (url == null) { + null + } else if (isVideoUrl(url)) { + MediaUrlVideo( + url = url, + description = noteEvent.content, + hash = null, + blurhash = noteEvent.blurhash(), + dim = noteEvent.dimensions(), + uri = null, + mimeType = noteEvent.mimeType(), + ) + } else { + MediaUrlImage( + url = url, + description = noteEvent.content, + hash = null, // We don't want to show the hash banner here + blurhash = noteEvent.blurhash(), + dim = noteEvent.dimensions(), + uri = null, + mimeType = noteEvent.mimeType(), + ) + } + } else if (noteEvent is PictureEvent) { + val imeta = noteEvent.imetaTags().firstOrNull() + if (imeta?.url == null) { + null + } else { + MediaUrlImage( + url = imeta.url, + description = noteEvent.content, + hash = null, // We don't want to show the hash banner here + blurhash = imeta.blurhash, + dim = imeta.dimension, + uri = null, + mimeType = imeta.mimeType, + ) + } + } else if (noteEvent is VideoEvent) { + val imeta = noteEvent.imetaTags().firstOrNull() + + if (imeta?.url == null) { + null + } else { + MediaUrlVideo( + url = imeta.url, + description = noteEvent.content, + hash = null, // We don't want to show the hash banner here + blurhash = imeta.blurhash, + dim = imeta.dimension, + uri = null, + mimeType = imeta.mimeType, + ) + } + } else { + null + } + + InnerRenderGalleryThumb(content, baseNote, accountViewModel) +} + +@Composable +fun InnerRenderGalleryThumb( + content: MediaUrlContent?, + note: Note, + accountViewModel: AccountViewModel, +) { + if (content != null) { + GalleryContentView( + content = content, + accountViewModel = accountViewModel, + ) + } else { + DisplayGalleryAuthorBanner(note) + } +} + +@Composable +fun DisplayGalleryAuthorBanner(note: Note) { + WatchAuthor(note) { author -> + BannerImage(author, Modifier.fillMaxSize().clip(QuoteBorder)) + } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun GalleryContentView( + content: MediaUrlContent, + accountViewModel: AccountViewModel, +) { + when (content) { + is MediaUrlImage -> + SensitivityWarning(content.contentWarning != null, accountViewModel) { + UrlImageView(content, accountViewModel) + } + is MediaUrlVideo -> + SensitivityWarning(content.contentWarning != null, accountViewModel) { + UrlVideoView(content, accountViewModel) + } + } +} + +@Composable +fun UrlImageView( + content: MediaUrlImage, + accountViewModel: AccountViewModel, + alwayShowImage: Boolean = false, +) { + val defaultModifier = Modifier.fillMaxSize().aspectRatio(1f) + + val showImage = + remember { + mutableStateOf( + if (alwayShowImage) true else accountViewModel.settings.showImages.value, + ) + } + + CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { + if (it) { + SubcomposeAsyncImage( + model = content.url, + contentDescription = content.description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) { + val state by painter.state.collectAsState() + when (state) { + is AsyncImagePainter.State.Loading, + -> { + if (content.blurhash != null) { + DisplayBlurHash( + content.blurhash, + content.description, + ContentScale.Crop, + defaultModifier, + ) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } + is AsyncImagePainter.State.Error -> { + ClickableUrl(urlText = "${content.url} ", url = content.url) + } + is AsyncImagePainter.State.Success -> { + SubcomposeAsyncImageContent(defaultModifier) + } + else -> {} + } + } + } else { + if (content.blurhash != null) { + DisplayBlurHash( + content.blurhash, + content.description, + ContentScale.Crop, + defaultModifier.clickable { showImage.value = true }, + ) + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, Color.White) + } + } else { + ImageUrlWithDownloadButton(content.url, showImage) + } + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun UrlVideoView( + content: MediaUrlVideo, + accountViewModel: AccountViewModel, +) { + val defaultModifier = Modifier.fillMaxSize().aspectRatio(1f) + + val automaticallyStartPlayback = + remember(content) { + mutableStateOf(accountViewModel.settings.startVideoPlayback.value) + } + + Box(defaultModifier, contentAlignment = Alignment.Center) { + if (content.blurhash != null) { + // Always displays Blurharh to avoid size flickering + DisplayBlurHash( + content.blurhash, + null, + ContentScale.Crop, + defaultModifier, + ) + } + + if (!automaticallyStartPlayback.value) { + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { automaticallyStartPlayback.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, Color.White) + } + } else { + GetMediaItem(content.url, content.description, content.artworkUri, content.authorName) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + videoUri = content.url, + defaultToStart = true, + nostrUriCallback = content.uri, + proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(content.url)), + ) { controller, keepPlaying -> + AndroidView( + modifier = Modifier, + factory = { context: Context -> + PlayerView(context).apply { + clipToOutline = true + player = controller + setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) + + controllerAutoShow = false + useController = false + + hideController() + + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL + + controller.playWhenReady = true + } + }, + ) + } + } + } + } +} 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 new file mode 100644 index 000000000..df7e5a2bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.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.profile.gallery + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.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.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size5dp + +@Composable +fun RenderGalleryFeed( + viewModel: FeedViewModel, + listState: LazyGridState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle() + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderDiscoverFeed", + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + GalleryFeedLoaded( + state, + listState, + accountViewModel, + nav, + ) + } + is FeedState.Loading -> { + LoadingFeed() + } + } + } +} + +@Composable +private fun GalleryFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyGridState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> + GalleryCardCompose( + baseNote = item, + modifier = + Modifier + .aspectRatio(1f) + .fillMaxSize() + .animateItem() + .padding(Size5dp), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt new file mode 100644 index 000000000..f49fe889d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.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.amethyst.ui.screen.loggedIn.profile.gallery + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialogOneButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun QuickActionGallery( + baseNote: Note, + accountViewModel: AccountViewModel, + content: @Composable (() -> Unit) -> Unit, +) { + val popupExpanded = remember { mutableStateOf(false) } + + content { popupExpanded.value = true } + + if (popupExpanded.value) { + if (baseNote.author == accountViewModel.account.userProfile()) { + DeleteFromGalleryDialog( + note = baseNote, + onDismiss = { popupExpanded.value = false }, + accountViewModel = accountViewModel, + ) + } + } +} + +@Composable +fun DeleteFromGalleryDialog( + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + QuickActionAlertDialogOneButton( + title = stringRes(R.string.quick_action_request_deletion_gallery_title), + textContent = stringRes(R.string.quick_action_request_deletion_gallery_alert_body), + buttonIcon = Icons.Default.Delete, + buttonText = stringRes(R.string.quick_action_delete_dialog_btn), + onClickDoOnce = { + accountViewModel.removefromMediaGallery(note) + onDismiss() + }, + onDismiss = onDismiss, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index becdd627c..747ddf6f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -74,15 +74,7 @@ class SearchBarViewModel( _hashtagResults.emit(findHashtags(searchValue)) _searchResultsUsers.emit( - LocalCache - .findUsersStartingWith(searchValue, account) - .sortedWith( - compareBy( - { it.toBestDisplayName().startsWith(searchValue, true) }, - { account.isFollowing(it) }, - { it.toBestDisplayName() }, - ), - ).reversed(), + LocalCache.findUsersStartingWith(searchValue, account), ) _searchResultsNotes.emit( LocalCache 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 c6799b546..e605da4fe 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 @@ -48,7 +48,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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.focus.focusRequester diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt index 66840aacd..4d70c3615 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt @@ -28,8 +28,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R 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 83dcfb017..6cba1dda9 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 @@ -25,6 +25,7 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -123,6 +124,7 @@ import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay 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.RenderEmojiPack @@ -131,6 +133,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight +import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll @@ -154,6 +157,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.lessImportantLink @@ -169,6 +173,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.DraftEvent @@ -182,9 +187,11 @@ import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -198,7 +205,6 @@ import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.WikiNoteEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -218,7 +224,7 @@ fun ThreadFeedView( nav = nav, routeForLastRead = null, onLoaded = { - RenderThreadFeed(noteId, it, viewModel.llState, viewModel::levelFlowForItem, accountViewModel, nav) + RenderThreadFeed(noteId, it, viewModel.llState, viewModel, accountViewModel, nav) }, ) } @@ -229,36 +235,38 @@ fun RenderThreadFeed( noteId: String, loaded: FeedState.Loaded, listState: LazyListState, - createLevelFlow: (Note) -> Flow, + viewModel: LevelFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() - LaunchedEffect(noteId, items.list) { + val position = items.list.indexOfFirst { it.idHex == noteId } + + LaunchedEffect(noteId, position) { // hack to allow multiple scrolls to Item while posts on the screen load. // This is important when clicking on a reply of an older thread in Notifications // In that case, this screen will open with 0-1 items, and the scrollToItem below // will not change the state of the screen (too few items, scroll is not available) // as the app loads the reaming of the thread the position of the reply changes - // and becuase there wasn't a possibility to scroll before and now there is one, + // and because there wasn't a possibility to scroll before and now there is one, // the screen stays at the top. Once the thread has enough replies, the lazy column - // updates with new items correctly. It just needs a few items to start the scrool. + // updates with new items correctly. It just needs a few items to start the scroll. // // This hack allows the list 1 second to fill up with more // records before setting up the position on the feed. // // It jumps around, but it is the best we can do. - if (listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 && items.list.size > 3) { - val position = items.list.indexOfFirst { it.idHex == noteId } - if (position >= 0) { + if (position >= 0 && !viewModel.hasDragged.value) { + val offset = if (position > items.list.size - 3) { - listState.scrollToItem(position, 0) + 0 } else { - listState.scrollToItem(position, -200) + -200 } - } + + listState.scrollToItem(position, offset) } } @@ -268,12 +276,11 @@ fun RenderThreadFeed( state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item -> - val level = createLevelFlow(item).collectAsStateWithLifecycle(0) + val level = viewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0) val modifier = Modifier .drawReplyLevel( - note = item, level = level, color = MaterialTheme.colorScheme.placeholderText, selected = @@ -321,7 +328,6 @@ fun RenderThreadFeed( // Creates a Zebra pattern where each bar is a reply level. fun Modifier.drawReplyLevel( - note: Note, level: State, color: Color, selected: Color, @@ -483,14 +489,11 @@ private fun FullBleedNoteCompose( Spacer(modifier = Modifier.height(10.dp)) - if (noteEvent is BadgeDefinitionEvent) { - BadgeDisplay(baseNote = baseNote) - } else if (noteEvent is LongTextNoteEvent) { - RenderLongFormHeaderForThread(noteEvent) - } else if (noteEvent is WikiNoteEvent) { - RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) - } else if (noteEvent is ClassifiedsEvent) { - RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav) + when (noteEvent) { + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) + is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent) + is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) + is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav) } Row( @@ -511,17 +514,19 @@ private fun FullBleedNoteCompose( nav = nav, ) } else if (noteEvent is VideoEvent) { - VideoDisplay(baseNote, false, true, backgroundColor, false, accountViewModel, nav) + VideoDisplay(baseNote, makeItShort = false, canPreview = true, backgroundColor = backgroundColor, ContentScale.FillWidth, accountViewModel = accountViewModel, nav = nav) + } else if (noteEvent is PictureEvent) { + PictureDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, PaddingValues(vertical = Size5dp), backgroundColor, accountViewModel = accountViewModel, nav) } else if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(baseNote, true, false, accountViewModel) + FileHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { - FileStorageHeaderDisplay(baseNote, true, false, accountViewModel) + FileStorageHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel) } else if (noteEvent is PeopleListEvent) { DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is AudioTrackEvent) { - AudioTrackHeader(noteEvent, baseNote, false, accountViewModel, nav) + AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is AudioHeaderEvent) { - AudioHeader(noteEvent, baseNote, false, accountViewModel, nav) + AudioHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, @@ -561,16 +566,38 @@ private fun FullBleedNoteCompose( RenderFhirResource(baseNote, accountViewModel, nav) } else if (noteEvent is GitRepositoryEvent) { RenderGitRepositoryEvent(baseNote, accountViewModel, nav) + } else if (noteEvent is InteractiveStoryBaseEvent) { + RenderInteractiveStory( + baseNote, + false, + true, + 3, + backgroundColor, + accountViewModel, + nav, + ) } else if (noteEvent is GitPatchEvent) { - RenderGitPatchEvent(baseNote, false, true, quotesLeft = 3, backgroundColor, accountViewModel, nav) + RenderGitPatchEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is GitIssueEvent) { - RenderGitIssueEvent(baseNote, false, true, quotesLeft = 3, backgroundColor, accountViewModel, nav) + RenderGitIssueEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is AppDefinitionEvent) { RenderAppDefinition(baseNote, accountViewModel, nav) } else if (noteEvent is DraftEvent) { RenderDraft(baseNote, 3, true, backgroundColor, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { RenderHighlight(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is CommentEvent) { + RenderTextEvent( + baseNote, + false, + canPreview, + quotesLeft = 3, + unPackReply = false, + backgroundColor, + editState, + accountViewModel, + nav, + ) } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is TextNoteModificationEvent) { @@ -661,9 +688,8 @@ private fun FullBleedNoteCompose( } } - val noteEvent = baseNote.event - val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } - if (zapSplits && noteEvent != null) { + val zapSplits = remember(noteEvent) { noteEvent.hasZapSplitSetup() } + if (zapSplits) { Spacer(modifier = DoubleVertSpacer) Row( modifier = Modifier.padding(horizontal = 12.dp), @@ -672,7 +698,7 @@ private fun FullBleedNoteCompose( } } - ReactionsRow(baseNote, true, true, editState, accountViewModel, nav) + ReactionsRow(baseNote, showReactionDetail = true, addPadding = true, editState = editState, accountViewModel = accountViewModel, nav = nav) } } @@ -924,14 +950,14 @@ private fun RenderWikiHeaderForThreadPreview() { RenderWikiHeaderForThread(noteEvent = event, accountViewModel = accountViewModel, nav) RenderTextEvent( baseNote!!, - false, - true, + makeItShort = false, + canPreview = true, quotesLeft = 3, unPackReply = false, - backgroundColor, - editState, - accountViewModel, - nav, + backgroundColor = backgroundColor, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt index 7d6e960c3..f95a87de1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt @@ -20,19 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video -import android.Manifest -import android.net.Uri -import android.os.Build -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -42,64 +34,50 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.outlined.Close -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.GallerySelect import com.vitorpamplona.amethyst.ui.actions.NewMediaModel import com.vitorpamplona.amethyst.ui.actions.NewMediaView -import com.vitorpamplona.amethyst.ui.actions.getPhotoUri +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(ExperimentalPermissionsApi::class) @Composable fun NewImageButton( accountViewModel: AccountViewModel, nav: INav, navScrollToTop: () -> Unit, ) { - val context = LocalContext.current - var isOpen by remember { mutableStateOf(false) } var wantsToPostFromGallery by remember { mutableStateOf(false) } var wantsToPostFromCamera by remember { mutableStateOf(false) } - var cameraUri by remember { mutableStateOf(null) } - - var pickedURI by remember { mutableStateOf(null) } + var pickedURIs by remember { mutableStateOf>(persistentListOf()) } val scope = rememberCoroutineScope() @@ -112,193 +90,111 @@ fun NewImageButton( } if (wantsToPostFromCamera) { - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.TakePicture(), - ) { success -> - if (success) { - cameraUri?.let { - pickedURI = it - } - } - cameraUri = null - wantsToPostFromCamera = false - } - - val cameraPermissionState = - rememberPermissionState( - Manifest.permission.CAMERA, - onPermissionResult = { - if (it) { - scope.launch(Dispatchers.IO) { - cameraUri = getPhotoUri(context) - cameraUri?.let { launcher.launch(it) } - } - } - }, - ) - - if (cameraPermissionState.status.isGranted) { - LaunchedEffect(key1 = accountViewModel) { - launch(Dispatchers.IO) { - cameraUri = getPhotoUri(context) - cameraUri?.let { launcher.launch(it) } - } - } - } else { - LaunchedEffect(key1 = accountViewModel) { cameraPermissionState.launchPermissionRequest() } + TakePicture { uri -> + wantsToPostFromCamera = false + pickedURIs = uri } } if (wantsToPostFromGallery) { - val cameraPermissionState = - rememberPermissionState( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Manifest.permission.READ_MEDIA_IMAGES - } else { - Manifest.permission.READ_EXTERNAL_STORAGE - }, - ) - - if (cameraPermissionState.status.isGranted) { - var showGallerySelect by remember { mutableStateOf(false) } - if (showGallerySelect) { - GallerySelect( - onImageUri = { uri -> - wantsToPostFromGallery = false - showGallerySelect = false - pickedURI = uri - }, - ) - } - - showGallerySelect = true - } else { - LaunchedEffect(key1 = accountViewModel) { cameraPermissionState.launchPermissionRequest() } - } + GallerySelect( + onImageUri = { uri -> + wantsToPostFromGallery = false + pickedURIs = uri + }, + ) } - pickedURI?.let { + if (pickedURIs.isNotEmpty()) { NewMediaView( - uri = it, - onClose = { pickedURI = null }, + uris = pickedURIs, + onClose = { pickedURIs = persistentListOf() }, postViewModel = postViewModel, accountViewModel = accountViewModel, nav = nav, ) } - if (postViewModel.isUploadingImage) { - ShowProgress(postViewModel) - } else { - Column { -// if (isOpen) { + Column { + AnimatedVisibility( + visible = isOpen, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + ) { + Column { + FloatingActionButton( + onClick = { + wantsToPostFromCamera = true + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = stringRes(id = R.string.upload_image), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + FloatingActionButton( + onClick = { + wantsToPostFromGallery = true + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = stringRes(id = R.string.upload_image), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + } + } + + FloatingActionButton( + onClick = { + isOpen = !isOpen + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { AnimatedVisibility( visible = isOpen, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + enter = fadeIn(), + exit = fadeOut(), ) { - Column { - FloatingActionButton( - onClick = { - wantsToPostFromCamera = true - isOpen = false - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - imageVector = Icons.Default.CameraAlt, - contentDescription = stringRes(id = R.string.upload_image), - modifier = Modifier.size(26.dp), - tint = Color.White, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - FloatingActionButton( - onClick = { - wantsToPostFromGallery = true - isOpen = false - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - imageVector = Icons.Default.AddPhotoAlternate, - contentDescription = stringRes(id = R.string.upload_image), - modifier = Modifier.size(26.dp), - tint = Color.White, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - } + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringRes(id = R.string.new_short), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) } - FloatingActionButton( - onClick = { - isOpen = !isOpen - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, + AnimatedVisibility( + visible = !isOpen, + enter = fadeIn(), + exit = fadeOut(), ) { - AnimatedVisibility( - visible = isOpen, - enter = fadeIn(), - exit = fadeOut(), - ) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = stringRes(id = R.string.new_short), - modifier = Modifier.size(26.dp), - tint = Color.White, - ) - } - - AnimatedVisibility( - visible = !isOpen, - enter = fadeIn(), - exit = fadeOut(), - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - contentDescription = stringRes(id = R.string.new_short), - modifier = Modifier.size(26.dp), - tint = Color.White, - ) - } + Icon( + painter = painterResource(R.drawable.ic_compose), + contentDescription = stringRes(id = R.string.new_short), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) } } } } - -@Composable -private fun ShowProgress(postViewModel: NewMediaModel) { - Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - progress = - animateFloatAsState( - targetValue = postViewModel.uploadingPercentage.value, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ).value, - modifier = - Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) - postViewModel.uploadingDescription.value?.let { - Text( - it, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) - } - } -} 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 c8320a038..f934e37f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -24,12 +24,14 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState @@ -43,9 +45,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle @@ -85,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay +import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.stringRes @@ -101,6 +105,7 @@ import com.vitorpamplona.amethyst.ui.theme.VideoReactionColumnPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.VideoEvent @Composable @@ -247,7 +252,7 @@ fun SlidingCarousel( val pagerState = if (pagerStateKey != null) { - rememberForeverPagerState(pagerStateKey, items.list.size) { items.list.size } + rememberForeverPagerState(pagerStateKey) { items.list.size } } else { rememberPagerState(items.list.size) { items.list.size } } @@ -290,17 +295,21 @@ private fun RenderVideoOrPictureNote( Column(Modifier.fillMaxSize(1f), verticalArrangement = Arrangement.Center) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { val noteEvent = remember { note.event } - if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(note, false, true, accountViewModel) + if (noteEvent is PictureEvent) { + val backgroundColor = remember { mutableStateOf(Color.Transparent) } + + PictureDisplay(note, false, ContentScale.Fit, PaddingValues(5.dp), backgroundColor, accountViewModel, nav) + } else if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(note, false, ContentScale.Fit, accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { - FileStorageHeaderDisplay(note, false, true, accountViewModel) + FileStorageHeaderDisplay(note, false, ContentScale.Fit, accountViewModel) } else if (noteEvent is VideoEvent) { - JustVideoDisplay(note, false, true, accountViewModel) + JustVideoDisplay(note, false, ContentScale.Fit, accountViewModel) } } } - Row(modifier = Modifier.fillMaxSize(1f), verticalAlignment = Alignment.Bottom) { + Row(modifier = Modifier.fillMaxSize(1f).navigationBarsPadding(), verticalAlignment = Alignment.Bottom) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.Center) { RenderAuthorInformation(note, nav, accountViewModel) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index e47bcb546..cc030afbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -92,7 +92,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.service.PackageUtils import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel @@ -467,7 +467,7 @@ fun PasswordField( onValueChange = onValueChange, keyboardOptions = KeyboardOptions( - autoCorrect = false, + autoCorrectEnabled = false, keyboardType = KeyboardType.Password, imeAction = ImeAction.Go, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt index f9b7cac89..77f73f43d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt @@ -129,7 +129,7 @@ fun SignUpPage( onValueChange = { displayName.value = it }, keyboardOptions = KeyboardOptions( - autoCorrect = false, + autoCorrectEnabled = false, keyboardType = KeyboardType.Text, imeAction = ImeAction.Go, ), @@ -208,7 +208,7 @@ fun SignUpPage( } if (displayName.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) + errorMessage = stringRes(context, R.string.name_is_required) } if (acceptedTerms.value && displayName.value.text.isNotBlank()) { 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 1818e6cd4..1d2427020 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 @@ -89,6 +89,7 @@ val Size5dp = 5.dp val Size10dp = 10.dp val Size12dp = 12.dp val Size13dp = 13.dp +val Size14dp = 14.dp val Size15dp = 15.dp val Size16dp = 16.dp val Size17dp = 17.dp 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 be46269fc..e1c66feca 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 @@ -54,7 +54,6 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat.getSystemService import androidx.core.view.WindowCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.halilibo.richtext.ui.RichTextStyle @@ -115,7 +114,10 @@ private val DarkSubtleButton = DarkColorPalette.onSurface.copy(alpha = 0.22f) private val LightSubtleButton = LightColorPalette.onSurface.copy(alpha = 0.22f) private val DarkSubtleBorder = DarkColorPalette.onSurface.copy(alpha = 0.12f) -private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.12f) +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 DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f) private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f) @@ -382,6 +384,9 @@ val ColorScheme.grayText: Color val ColorScheme.subtleBorder: Color get() = if (isLight) LightSubtleBorder else DarkSubtleBorder +val ColorScheme.chatBackground: Color + get() = if (isLight) LightChatBackground else DarkChatBackground + val ColorScheme.subtleButton: Color get() = if (isLight) LightSubtleButton else DarkSubtleButton diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index 5272101ee..446476e24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -27,7 +27,7 @@ import android.content.ServiceConnection import android.os.IBinder import android.util.Log import androidx.appcompat.app.AppCompatActivity.BIND_AUTO_CREATE -import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import org.torproject.jni.TorService import org.torproject.jni.TorService.LocalBinder diff --git a/amethyst/src/main/res/values-ar/strings.xml b/amethyst/src/main/res/values-ar/strings.xml index 3a37d7515..8833f69d3 100644 --- a/amethyst/src/main/res/values-ar/strings.xml +++ b/amethyst/src/main/res/values-ar/strings.xml @@ -326,6 +326,7 @@ جميع المتابعات العالمي قائمة الحسابات المكتومة + أساسي الرسائل الخاصة التنبيه عند وصول رسالة خاصة من طرف %1$s diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index a16e38849..f43160d58 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -74,7 +74,7 @@ "%1$s এর জন্য কার্ড ইমেজের পূর্বরূপ দেখুন" নতুন চ্যানেল চ্যানেলের নাম - আমার চম‍‌‌‍ৎকার গ্রুপ + আমার অসাধারণ দল ছবির Url বিবরণ "আমাদের সম্পর্কে.. " @@ -506,6 +506,7 @@ স্বাক্ষরের অ্যাপ খোলা যাচ্ছে না স্বাক্ষরের অনুরোধ প্রত্যাখ্যান করা হয়েছে কোন প্রতিক্রিয়া সেটআপ + একটি নির্বাচন করুন UnifiedPush অ্যাপ কোনটি নয় বিক্রেতাকে একটি বার্তা পাঠান হাই %1$s, এটা কি এখনও পাওয়া যায়? @@ -518,6 +519,7 @@ অবস্থান শহর, রাজ্য, দেশ নতুন + এটি একটি সম্পূর্ণ নতুন ইউনিট, মূল বাক্সে। ভাল ন্যায্য পোশাক @@ -528,15 +530,22 @@ নীড় দপ্তর খাদ্য + নীড় + অনুসন্ধান করুন + আবিস্কার + বার্তা নিরাপত্তা-ফিল্টার নতুন পোস্ট উত্তর বুস্ট বা উদ্ধৃতি লাইক দিন + এর প্রোফাইল ছবি %1$s নোট অপশন ভোট পোল নিষ্ক্রিয় করুন অবস্থান ঠিকানা সঠিক নয় + আপনার অনুদান আমাদের পার্থক্য গড়তে সাহায্য করে। প্রতিটি ইঞ্চি গুরুত্বপূর্ণ! + এখনই দান করুন QR কোড স্ক্যান করুন diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index ddb83874e..bdcbf2f41 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -116,10 +116,13 @@ URL avatara URL banneru URL webové stránky + Zájmena LN adresa LN URL (zastaralé) Uložit do galerie Obrázek uložen do galerie + Stahování videa bylo zahájeno… + Stahování médií bylo zahájeno… Chyba při ukládání obrázku Video uloženo do video galerie telefonu Nepodařilo se uložit video @@ -147,6 +150,8 @@ Lightning adresa Zkopíruje Nsec ID (vaše heslo) do schránky pro zálohování Zkopírovat tajný klíč do schránky + Zobrazit QR kód privátního klíče + Zobrazit šifrovaný QR kód soukromého klíče Zkopíruje veřejný klíč do schránky pro sdílení Zkopírovat veřejný klíč (NPub) do schránky Poslat přímou zprávu @@ -183,6 +188,7 @@ Načítání účtu "Chyba při načítání odpovědí: " Zkusit znovu + Zatím žádná oznámení. Zdroj je prázdný. Obnovit vytvořeno @@ -202,6 +208,8 @@ přeloženo z do Zobrazit nejprve v %1$s + Veřejný chat o %1$s + Veřejná komunita o %1$s Vždy překládat do %1$s Nikdy nepřekládat z %1$s Nostr Adresa @@ -330,10 +338,13 @@ Co to znamená? Obrázek je stejný jako v původním příspěvku Obrázek se změnil. Autor možná změnu nezaznamenal + Přidat média Přidat obrázek Přidat video Přidat dokument Přidat do zprávy + Přidat titulek + Můj milý přítel Popis obsahu Modrá loď na bílé písčité pláži při západu slunce Typ zapsu @@ -347,15 +358,25 @@ Né Zap Žádná stopa v Nostr, pouze v Lightning Souborový server + Zvolte server pro nahrání tohoto souboru LnAddress nebo @Uživatel Media Servery Nastavte preferované servery pro nahrávání médií. - Nemáte nastaveny žádné vlastní mediální servery. Můžete použít Amethystův seznam nebo přidat jeden níže + Nemáte nastaveny žádné servery NIP-96. Můžete použít Amethystův seznam nebo přidat některý níže ↓ + Nemáte nastaveny žádné Blossom servery. Můžete použít Amethystův seznam, nebo přidat některý níže ↓ Integrované mediální servery Výchozí seznam Amethysty. Můžete je přidat jednotlivě nebo přidat seznam. Použít výchozí seznam Přidat server médií Odstranit server médií + Nezačaté + Kompresuje + Nahrávání + Zpracovává se + Stahování + Hashování + Hotovo + Chyba Vaše relé (NIP-95) Soubory jsou hostovány vašimi relé. Nový NIP: zkontrolujte, zda je podporují Nastavení ochrany soukromí @@ -369,6 +390,7 @@ Ne Seznam sledovaných Všechna sledování + Kolem mě Globální Seznam ztlumení Výchozí port je 9050 @@ -538,11 +560,16 @@ Kontrola adresy Nostr Vybrat/Zrušit vše Výchozí + Obnovit výchozí nastavení Vyberte relé pro pokračování Přeposílat Zapy na: Podporující klienti budou přeposílat Zapy na níže uvedenou LN adresu nebo uživatelský profil místo vaší Zveřejnit polohu jako Přidá Geohash vaší polohy do příspěvku. Veřejnost bude vědět, že se nacházíte do 5 km od aktuální polohy + Lokace-Exkluzívní příspěvek + Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí. + Načítání umístění + Žádná lokace oprávnění Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující Nová funkce Aktivace tohoto režimu vyžaduje od Amethystu odeslání zprávy NIP-17 (GiftWrapped, Zapečetěné přímé a skupinové zprávy). NIP-17 je nový a většina klientů ho zatím neimplementovala. Ujistěte se, že příjemce používá kompatibilního klienta. @@ -571,6 +598,8 @@ Kopírovat URL do schránky Kopírovat ID poznámky do schránky Přidat média do galerie + Média přidána + Média přidána do vaší profilové galerie Vytvořeno Pravidla Přihlásit se pomocí Amber @@ -705,7 +734,9 @@ Chyba při nahrávání: %1$s Server po nahrání neposkytl URL Nepodařilo se stáhnout nahraná média ze serveru + Nelze zkontrolovat stažený soubor po nahrání: %1$s Nelze připravit místní soubor k nahrání: %1$s + Nepodařilo se dosáhnout %1$s: %2$s Nahrání selhalo: %1$s Smazání selhalo: %1$s Média je příliš velká pro NIP-95 @@ -767,6 +798,7 @@ Nastavte svá relé soukromé schránky Toto nastavení umožňuje všem vědět, která relé použít při posílání zpráv vám. Bez nich můžete některé zprávy zmeškat. Dobré možnosti jsou:\n - inbox.nostr.wine (placené)\n - you.nostr1.com (osobní relé - placené) + Dobré možnosti jsou:\n - auth.nostr1.com (zdarma)\n - inbox.nostr.wine (placeno)\n - relay.0xchat.com (zdarma) Vložte 1–3 relé, která budou sloužit jako vaše soukromá schránka. Relé DM schránky by měla přijímat jakékoli zprávy od kohokoli, ale pouze vám umožnit jejich stahování. Nastavit nyní Vyhledávací relé @@ -820,6 +852,7 @@ Zapečetěná zpráva zapnuta. Klikněte pro vypnutí zapečetěné zprávy Odeslat Přehrát uživatelské jméno jako audio + Připínáček Skenovat QR kód Přejít na poskytovatele peněženky třetí strany Alby Není možné odpovědět na koncept @@ -862,7 +895,12 @@ Nedostatečné úložiště - Server nemá dostatek úložiště, aby úspěšně zpracoval požadavek Detekována smyčka - Server detekuje nekonečnou smyčku při zpracování požadavku Vyžadováno ověření sítě - Klient musí být autentizován, aby získal přístup k síti + NIP-96 Servery + Přidejte tolik serverů, kolik chcete. Můžete si vybrat, které použít později při nahrávání obrázku + Blossom Servery + Přidejte tolik serverů, kolik chcete. Můžete si vybrat, které použít později při nahrávání obrázku Přidat NIP-96 server + Přidat Blossom Server Vymazat vše Opravdu chcete odstranit všechny koncepty? Zásobník: diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index e1d1084e5..797573b0a 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -116,12 +116,15 @@ Avatar-URL Banner-URL Website-URL + Pronomen LN-Adresse LN-URL (veraltet) In die Galerie abspeichern Bild in der Gal erie gespeichert + Der Videodownload wurde gestartet… + Der Mediendownload wurde gestartet… Fehler beim Speichern des Bildes Video in der Videogalerie des Telefons gespeichert Video konnte nicht gespeichert werden @@ -149,6 +152,8 @@ erie gespeichert Lightning-Adresse Kopiert die Nsec-ID (Ihr Passwort) zur Sicherung in die Zwischenablage Kopiere geheimen Schlüssel in die Zwischenablage + Privaten QR-Code anzeigen + QR-Code für verschlüsselten privaten Schlüssel anzeigen Kopiert den öffentlichen Schlüssel zum Teilen in die Zwischenablage Kopiere öffentlichen Schlüssel (NPub) in die Zwischenablage Sende eine Direktnachricht @@ -187,6 +192,7 @@ anz der Bedingungen ist erforderlich Konto wird geladen "Fehler beim Laden der Antworten: " Erneut versuchen + Noch keine Benachrichtigungen. Feed ist leer. Aktualisieren erstellt @@ -206,6 +212,8 @@ anz der Bedingungen ist erforderlich übersetzt von zu Zuerst in %1$s anzeigen + Öffentlicher Chat über %1$s + Öffentliche Gemeinschaft über %1$s Immer ins %1$s übersetzen Niemals aus dem %1$s übersetzen Nostr-Adresse @@ -336,10 +344,13 @@ anz der Bedingungen ist erforderlich Was bedeutet das? Das Bild ist seitdem Beitrag dasselbe Das Bild hat sich geändert. Der Autor hat die Änderung möglicherweise nicht bemerkt. + Medien hinzufügen Bild hinzufügen Video hinzufügen Dokument hinzufügen Zur Nachricht hinzufügen + Untertitel hinzufügen + Mein netter Freund Beschreibung des Inhalts Ein blaues Boot an einem weißen Sandstrand bei Sonnenuntergang Zap-Typ @@ -353,15 +364,25 @@ anz der Bedingungen ist erforderlich Keine Zap Keine Spur in Nostr, nur in Lightning Dateiserver + Wählen Sie einen Server zum Hochladen dieser Datei LnAddress oder @Benutzer Medienserver Legen Sie Ihre bevorzugten Medienupload Server fest. - Sie haben keine benutzerdefinierten Medienserver gesetzt. Sie können die Liste von Amethyst verwenden oder unten einen hinzufügen + Du hast keine NIP-96-Server gesetzt. Du kannst die Amethyst-Liste verwenden oder unten einen hinzufügen ↓ + Du hast keine Blossom Server gesetzt. Du kannst die Amethyst-Liste verwenden oder unten einen hinzufügen ↓ Integrierte Medienserver Amethysts Standardliste. Sie können sie einzeln hinzufügen oder die Liste hinzufügen. Standardliste verwenden Medienserver hinzufügen Medienserver löschen + Noch nicht angefangen + Komprimieren + Hochladen + Verarbeitung + Herunterladen + Hashen + Fertig + Fehler Deine Relays (NIP-95) Dateien werden von deinen Relays gehostet. Neuer NIP: Überprüfe, ob sie unterstützt werden Privatsphäre-Optionen @@ -375,6 +396,7 @@ anz der Bedingungen ist erforderlich Nein Folgen-Liste Alle Folgen + In der Nähe Weltweit Stummliste Standard-Port ist 9050 @@ -543,11 +565,16 @@ anz der Bedingungen ist erforderlich Nostr-Adresse wird überprüft Alle auswählen/abwählen Standard + Auf Standardeinstellung zurücksetzen Wählen Sie ein Relais aus, um fortzufahren Weiterleiten von Zaps an: Unterstützende Clients leiten Zaps an die LNAddress oder das Benutzerprofil unten weiter, anstatt an Ihre eigene Adresse Ort preisgeben als Fügt dem Beitrag einen Geohash Ihres Standorts hinzu. Die Öffentlichkeit wird wissen, dass Sie sich innerhalb von 5 km (3 mi) vom aktuellen Standort befinden + Standort-exklusiver Beitrag + Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen. + Standort wird geladen + Keine Standortberechtigungen Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten Neues Feature Um diesen Modus zu aktivieren, muss Amethyst eine NIP-17-Nachricht senden (GiftWrapped, Versiegelte Direkt- und Gruppennachrichten). NIP-17 ist neu und die meisten Clients haben es noch nicht implementiert. Stellen Sie sicher, dass der Empfänger einen kompatiblen Client verwendet. @@ -576,6 +603,8 @@ anz der Bedingungen ist erforderlich URL in die Zwischenablage kopieren Notiz-ID in die Zwischenablage kopieren Medien zur Galerie hinzufügen + Medien hinzugefügt + Medien wurden zu Ihrer Profilgalerie hinzugefügt Erstellt am Regeln Mit Amber anmelden @@ -710,7 +739,9 @@ anz der Bedingungen ist erforderlich Fehler beim Hochladen: %1$s Der Server hat nach dem Hochladen keine URL angegeben Hochgeladene Medien konnten nicht vom Server heruntergeladen werden + Die heruntergeladene Datei konnte nach dem Hochladen nicht überprüft werden: %1$s Lokale Datei konnte nicht zum Hochladen vorbereitet werden: %1$s + Konnte %1$s nicht erreichen: %2$s Upload fehlgeschlagen: %1$s Löschen fehlgeschlagen: %1$s Medien sind zu groß für NIP-95 @@ -772,6 +803,7 @@ anz der Bedingungen ist erforderlich Richten Sie Ihre privaten Posteingangs-Relais ein Diese Einstellung lässt alle wissen, welche Relais verwendet werden sollen, wenn sie Nachrichten an Sie senden. Ohne diese könnten Sie einige Nachrichten verpassen. Gute Optionen sind:\n - inbox.nostr.wine (bezahlt)\n - you.nostr1.com (persönliche Relais - bezahlt) + Gute Optionen sind:\n - auth.nostr1.com (gratis)\n - inbox.nostr.wine (bezahlt)\n - relay.0xchat.com (gratis) Fügen Sie 1–3 Relais ein, die als Ihr privater Posteingang dienen sollen. DM Posteingangs-Relais sollten Nachrichten von jedem akzeptieren, aber nur Ihnen erlauben, sie herunterzuladen. Jetzt einrichten Suchrelais @@ -825,6 +857,7 @@ anz der Bedingungen ist erforderlich Versiegelte Nachricht an. Klicken Sie, um die versiegelte Nachricht auszuschalten Senden Benutzernamen als Audio abspielen + Reißzwecke QR-Code scannen Navigieren Sie zum Drittanbieter-Wallet-Anbieter Alby Es ist nicht möglich, auf einen Entwurf zu antworten @@ -867,7 +900,12 @@ anz der Bedingungen ist erforderlich Unzureichender Speicher - Der Server hat nicht genug Speicher, um die Anfrage erfolgreich zu verarbeiten Schleife erkannt - Der Server erkennt eine Endlosschleife, während er die Anfrage bearbeitet Netzwerk-Authentifizierung erforderlich - Der Client muss authentifiziert werden, um auf das Netzwerk zuzugreifen + NIP-96 Server + Fügen Sie beliebig viele Server hinzu. Sie können wählen, welche später beim Hochladen Ihres Bildes verwendet werden soll + Blossom Server + Fügen Sie beliebig viele Server hinzu. Sie können wählen, welche später beim Hochladen Ihres Bildes verwendet werden soll NIP-96 Server hinzufügen + Blossom Server hinzufügen Alles löschen Möchten Sie wirklich alle Entwürfe löschen? Stapel: diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 6d415cad9..56d431e63 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -116,6 +116,7 @@ URL del avatar URL del banner Dirección web + Pronombres Dirección LN Dirección LN (antigua) Guardar en la galería @@ -147,6 +148,8 @@ Dirección Lightning Copia el ID Nsec (tu contraseña) en el portapapeles para hacer una copia de seguridad Copiar clave privada al portapapeles + Mostrar código QR de clave privada + Mostrar código QR de clave privada cifrada Copia la clave pública al portapapeles para compartir Copiar la clave pública (NPub) al portapapeles Enviar un mensaje directo @@ -183,6 +186,7 @@ Cargando cuenta "Error al cargar las respuestas: " Intentar otra vez + Aún no hay notificaciones. Tablón vacío Actualizar Creado @@ -202,6 +206,8 @@ traducido de a Mostrar en %1$s primero + Chat público sobre %1$s + Comunidad pública sobre %1$s Traducir siempre a %1$s Nunca traducir desde %1$s Dirección de Nostr @@ -332,10 +338,13 @@ ¿Qué significa esto? Este contenido es el mismo desde la publicación Este contenido ha cambiado. Es posible que el autor no haya visto o aprobado el cambio. + Añadir contenido multimedia Añadir imagen Añadir vídeo Añadir documento Añadir al mensaje + Añadir un título + Mi amigo encantador Descripción del contenido Un bote azul en una playa de arena blanca al atardecer Tipo de zap @@ -349,15 +358,25 @@ No zap No hay rastro en Nostr, solo en Lightning Servidor de archivos + Elegir un servidor para cargar este archivo Dirección o @usuario de Lightning Servidores multimedia Configura tus servidores preferidos para subir contenido multimedia. - No tienes ningún servidor multimedia personalizado. Puedes utilizar la lista de Amethyst, o agregar uno de la lista a continuación ↓ + No tienes ningún servidor NIP-96 configurado. Puedes utilizar la lista de Amethyst o añadir uno de la lista a continuación ↓ + No tienes ningún servidor Blossom configurado. Puedes utilizar la lista de Amethyst o añadir uno de la lista a continuación ↓ Servidores multimedia integrados Lista predeterminada de Amethyst. Puedes agregarlos individualmente o añadir la lista. Usar lista predeterminada Agregar servidor multimedia Eliminar servidor multimedia + Sin iniciar + Comprimiendo + Cargando + Procesando + Descargando + Cifrando + Listo + Error Tus relés (NIP-95) Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles. Opciones de privacidad @@ -371,6 +390,7 @@ No Lista de seguidos Todos los seguidos + A mi alrededor Global Lista de silenciados El puerto predeterminado es 9050 @@ -540,11 +560,16 @@ Verificando dirección de Nostr Seleccionar o deseleccionar todo Predeterminados + Restablecer valores predeterminados Seleccionar un relé para continuar Reenviar zaps a: Los clientes compatibles reenviarán los zaps a la dirección de Lightning o al perfil de usuario indicados a continuación en lugar de a los tuyos. Revelar ubicación como Agrega un Geohash de tu ubicación al mensaje. El público sabrá que te encuentras a menos de 5 km (3 mi) de la ubicación actual. + Publicación con ubicación exclusiva + Solo los seguidores de la ubicación la verán. Tus seguidores generales no la verán. + Cargando ubicación + Sin permisos de ubicación Agrega una advertencia de contenido delicado antes de mostrarlo. Esto es ideal para cualquier contenido NSFW o que a algunas personas les pueda resultar ofensivo o perturbador. Función nueva La activación de este modo requiere que Amethyst envíe un mensaje NIP-17 (mensajes directos sellados, de grupo y “GiftWrapped\"). NIP-17 es nuevo y la mayoría de los clientes aún no lo han implementado. Comprueba que el destinatario use un cliente compatible. @@ -707,6 +732,7 @@ Error de carga: %1$s El servidor no proporcionó una URL después de la carga No se pudo descargar el contenido cargado desde el servidor + No se pudo comprobar el archivo descargado después de cargar: %1$s No se pudo preparar el archivo local para cargar: %1$s Error al subir: %1$s Error al eliminar: %1$s @@ -769,6 +795,7 @@ Configura tus relés de buzón de entrada privados Esta configuración permite que todo el mundo sepa qué relés usar cuando te envíen mensajes. Sin ellos, es posible que no recibas algunos mensajes. Algunas buenas opciones son:\n - inbox.nostr.wine (de pago)\n - you.nostr1.com (relés personales; de pago) + Algunas buenas opciones son:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free) Inserta entre 1 y 3 relés para que te sirvan de buzón de entrada privado. Los relés del buzón de entrada para mensajes directos deberían aceptar cualquier mensaje de cualquier persona, pero solo te permiten descargarlos. Configurar ahora Relés de búsqueda @@ -822,6 +849,7 @@ Mensaje sellado activado. Haz clic para desactivar el mensaje sellado. Enviar Reproducir nombre de usuario como audio + Marcador Escanear código QR Ir al proveedor externo de monederos Alby No es posible responder a un borrador de nota @@ -864,7 +892,12 @@ Almacenamiento insuficiente: el servidor no dispone de almacenamiento suficiente para procesar correctamente la solicitud. Bucle detectado: el servidor detecta un bucle infinito al procesar la solicitud. Autenticación de red requerida: el cliente debe estar autenticado para acceder a la red. + Servidores NIP-96 + Añade tantos servidores como quieras. Puedes elegir cuál utilizar más tarde al cargar tu foto. + Servidores Blossom + Añade tantos servidores como quieras. Puedes elegir cuál utilizar más tarde al subir tu foto. Agregar servidor NIP-96 + Añadir un servidor Blossom Borrar todo ¿Seguro quieres eliminar todos los borradores? Stack: diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index a3a2d59cc..15ef58996 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -116,6 +116,7 @@ URL del avatar URL del banner URL del sitio web + Pronombres Dirección de Lightning URL de Lightning (obsoleta) Guardar en la galería @@ -147,6 +148,8 @@ Dirección de Lightning Copia el ID Nsec (tu contraseña) en el portapapeles para hacer una copia de seguridad Copiar la clave secreta al portapapeles + Mostrar código QR de clave privada + Mostrar código QR de clave privada cifrada Copia la clave pública al portapapeles para compartirla Copiar la clave pública (NPub) al portapapeles Enviar un mensaje directo @@ -183,6 +186,7 @@ Cargando cuenta "Error al cargar las respuestas: " Intentar de nuevo + Aún no hay notificaciones. El feed está vacío. Actualizar creado @@ -202,6 +206,8 @@ traducido del al Mostrar primero en %1$s + Chat público sobre %1$s + Comunidad pública sobre %1$s Traducir siempre al %1$s No traducir nunca del %1$s Dirección de Nostr @@ -332,10 +338,13 @@ ¿Qué significa esto? Este contenido es el mismo desde la publicación Este contenido cambió. Es posible que el autor no haya visto o aprobado el cambio + Agregar contenido multimedia Agregar imagen Agregar video Agregar documento Agregar al mensaje + Agregar un título + Mi amigo encantador Descripción del contenido Un bote azul en una playa de arena blanca al atardecer Tipo de zap @@ -349,15 +358,25 @@ No zap No hay rastro en Nostr, solo en Lightning Servidor de archivos + Elegir un servidor para subir este archivo Dirección o @usuario de Lightning Servidores multimedia Configura tus servidores preferidos para subir contenido multimedia. - No tienes ningún servidor multimedia personalizado. Puedes utilizar la lista de Amethyst, o agregar uno de la lista a continuación ↓ + No tienes ningún servidor NIP-96 configurado. Puedes usar la lista de Amethyst o agregar uno de la lista a continuación ↓ + No tienes ningún servidor Blossom configurado. Puedes usar la lista de Amethyst o agregar uno de la lista a continuación ↓ Servidores multimedia integrados Lista predeterminada de Amethyst. Puedes agregarlos individualmente o añadir la lista. Usar lista predeterminada Agregar servidor multimedia Eliminar servidor multimedia + Sin iniciar + Comprimiendo + Subiendo + Procesando + Descargando + Cifrando + Listo + Error Tus relés (NIP-95) Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles. Opciones de privacidad @@ -371,6 +390,7 @@ No Lista de seguidos Todos los seguidos + A mi alrededor Global Lista de silenciados El puerto predeterminado es 9050 @@ -540,11 +560,16 @@ Verificando dirección de Nostr Seleccionar o deseleccionar todo Predeterminados + Restablecer valores predeterminados Seleccionar un relé para continuar Reenviar zaps a: Los clientes compatibles reenviarán los zaps a la dirección de Lightning o al perfil de usuario indicados a continuación en lugar de a los tuyos. Revelar ubicación como Agrega un Geohash de tu ubicación al mensaje. El público sabrá que te encuentras a menos de 5 km (3 mi) de la ubicación actual. + Publicación con ubicación exclusiva + Solo los seguidores de la ubicación la verán. Tus seguidores generales no la verán. + Cargando ubicación + Sin permisos de ubicación Agrega una advertencia de contenido delicado antes de mostrarlo. Esto es ideal para cualquier contenido NSFW o que a algunas personas les pueda resultar ofensivo o perturbador. Función nueva La activación de este modo requiere que Amethyst envíe un mensaje NIP-17 (mensajes directos sellados, de grupo y “GiftWrapped\"). NIP-17 es nuevo y la mayoría de los clientes aún no lo han implementado. Comprueba que el destinatario use un cliente compatible. @@ -707,6 +732,7 @@ Error de subida: %1$s El servidor no proporcionó una URL después de la subida No se pudo descargar el contenido subido desde el servidor + No se pudo comprobar el archivo descargado después de subir: %1$s No se pudo preparar el archivo local para subir: %1$s Error al subir: %1$s Error al eliminar: %1$s @@ -769,6 +795,7 @@ Configura tus relés de buzón de entrada privados Esta configuración permite que todo el mundo sepa qué relés usar cuando te envíen mensajes. Sin ellos, es posible que no recibas algunos mensajes. Algunas buenas opciones son:\n - inbox.nostr.wine (de pago)\n - you.nostr1.com (relés personales; de pago) + Algunas buenas opciones son:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free) Inserta entre 1 y 3 relés para que te sirvan de buzón de entrada privado. Los relés del buzón de entrada para mensajes directos deberían aceptar cualquier mensaje de cualquier persona, pero solo te permiten descargarlos. Configurar ahora Relés de búsqueda @@ -822,6 +849,7 @@ Mensaje sellado activado. Haz clic para desactivar el mensaje sellado. Enviar Reproducir nombre de usuario como audio + Marcador Escanear código QR Ir al proveedor externo de billeteras Alby No es posible responder a un borrador de nota @@ -864,7 +892,12 @@ Almacenamiento insuficiente: el servidor no dispone de almacenamiento suficiente para procesar correctamente la solicitud. Bucle detectado: el servidor detecta un bucle infinito al procesar la solicitud. Autenticación de red requerida: el cliente debe estar autenticado para acceder a la red. + Servidores NIP-96 + Agrega tantos servidores como quieras. Puedes elegir cuál usar más tarde al subir tu foto. + Servidores Blossom + Agrega tantos servidores como quieras. Puedes elegir cuál usar más tarde al subir tu foto. Agregar servidor NIP-96 + Agregar un servidor Blossom Borrar todo ¿Seguro quieres eliminar todos los borradores? Stack: diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 32ba7edbf..71edf6cd5 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -3,7 +3,7 @@ Apunta al código QR Mostrar QR Imagen de perfil - Tu imagen de perfil + Subiendo Escanear código QR Mostrar de todos modos Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas @@ -116,6 +116,7 @@ URL del avatar URL del banner URL del sitio web + Pronombres Dirección de Lightning URL de Lightning (obsoleta) Guardar en la galería @@ -147,6 +148,8 @@ Dirección de Lightning Copia el ID Nsec (tu contraseña) en el portapapeles para hacer una copia de seguridad Copiar la clave secreta al portapapeles + Mostrar código QR de clave privada + Mostrar código QR de clave privada cifrada Copia la clave pública al portapapeles para compartirla Copiar la clave pública (NPub) al portapapeles Enviar un mensaje directo @@ -183,6 +186,7 @@ Cargando cuenta "Error al cargar las respuestas: " Intentar de nuevo + Aún no hay notificaciones. El feed está vacío. Actualizar creado @@ -202,6 +206,8 @@ traducido del al Mostrar primero en %1$s + Chat público sobre %1$s + Comunidad pública sobre %1$s Traducir siempre al %1$s No traducir nunca del %1$s Dirección de Nostr @@ -332,10 +338,13 @@ ¿Qué significa esto? Este contenido es el mismo desde la publicación Este contenido cambió. Es posible que el autor no haya visto o aprobado el cambio + Agregar contenido multimedia Agregar imagen Agregar video Agregar documento Agregar al mensaje + Agregar un título + Mi amigo encantador Descripción del contenido Un bote azul en una playa de arena blanca al atardecer Tipo de zap @@ -349,15 +358,25 @@ No zap No hay rastro en Nostr, solo en Lightning Servidor de archivos + Elegir un servidor para subir este archivo Dirección o @usuario de Lightning Servidores multimedia Configura tus servidores preferidos para subir contenido multimedia. - No tienes ningún servidor multimedia personalizado. Puedes utilizar la lista de Amethyst, o agregar uno de la lista a continuación ↓ + No tienes ningún servidor NIP-96 configurado. Puedes usar la lista de Amethyst o agregar uno de la lista a continuación ↓ + No tienes ningún servidor Blossom configurado. Puedes usar la lista de Amethyst o agregar uno de la lista a continuación ↓ Servidores multimedia integrados Lista predeterminada de Amethyst. Puedes agregarlos individualmente o añadir la lista. Usar lista predeterminada Agregar servidor multimedia Eliminar servidor multimedia + Sin iniciar + Comprimiendo + Subiendo + Procesando + Descargando + Cifrando + Listo + Error Tus relés (NIP-95) Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles. Opciones de privacidad @@ -371,6 +390,7 @@ No Lista de seguidos Todos los seguidos + A mi alrededor Global Lista de silenciados El puerto predeterminado es 9050 @@ -540,11 +560,16 @@ Verificando dirección de Nostr Seleccionar o deseleccionar todo Predeterminados + Restablecer valores predeterminados Seleccionar un relé para continuar Reenviar zaps a: Los clientes compatibles reenviarán los zaps a la dirección de Lightning o al perfil de usuario indicados a continuación en lugar de a los tuyos. Revelar ubicación como Agrega un Geohash de tu ubicación al mensaje. El público sabrá que te encuentras a menos de 5 km (3 mi) de la ubicación actual. + Publicación con ubicación exclusiva + Solo los seguidores de la ubicación la verán. Tus seguidores generales no la verán. + Cargando ubicación + Sin permisos de ubicación Agrega una advertencia de contenido delicado antes de mostrarlo. Esto es ideal para cualquier contenido NSFW o que a algunas personas les pueda resultar ofensivo o perturbador. Función nueva La activación de este modo requiere que Amethyst envíe un mensaje NIP-17 (mensajes directos sellados, de grupo y “GiftWrapped\"). NIP-17 es nuevo y la mayoría de los clientes aún no lo han implementado. Comprueba que el destinatario use un cliente compatible. @@ -707,6 +732,7 @@ Error de subida: %1$s El servidor no proporcionó una URL después de la subida No se pudo descargar el contenido subido desde el servidor + No se pudo comprobar el archivo descargado después de subir: %1$s No se pudo preparar el archivo local para subir: %1$s Error al subir: %1$s Error al eliminar: %1$s @@ -769,6 +795,7 @@ Configura tus relés de buzón de entrada privados Esta configuración permite que todo el mundo sepa qué relés usar cuando te envíen mensajes. Sin ellos, es posible que no recibas algunos mensajes. Algunas buenas opciones son:\n - inbox.nostr.wine (de pago)\n - you.nostr1.com (relés personales; de pago) + Algunas buenas opciones son:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free) Inserta entre 1 y 3 relés para que te sirvan de buzón de entrada privado. Los relés del buzón de entrada para mensajes directos deberían aceptar cualquier mensaje de cualquier persona, pero solo te permiten descargarlos. Configurar ahora Relés de búsqueda @@ -822,6 +849,7 @@ Mensaje sellado activado. Haz clic para desactivar el mensaje sellado. Enviar Reproducir nombre de usuario como audio + Marcador Escanear código QR Ir al proveedor externo de billeteras Alby No es posible responder a un borrador de nota @@ -864,7 +892,12 @@ Almacenamiento insuficiente: el servidor no dispone de almacenamiento suficiente para procesar correctamente la solicitud. Bucle detectado: el servidor detecta un bucle infinito al procesar la solicitud. Autenticación de red requerida: el cliente debe estar autenticado para acceder a la red. + Servidores NIP-96 + Agrega tantos servidores como quieras. Puedes elegir cuál utilizar más tarde al subir tu foto. + Servidores Blossom + Agrega tantos servidores como quieras. Puedes elegir cuál usar más tarde al subir tu foto. Agregar servidor NIP-96 + Agregar un servidor Blossom Borrar todo ¿Seguro quieres eliminar todos los borradores? Stack: diff --git a/amethyst/src/main/res/values-et-rEE/strings.xml b/amethyst/src/main/res/values-et-rEE/strings.xml index 66a570486..3ea04e700 100644 --- a/amethyst/src/main/res/values-et-rEE/strings.xml +++ b/amethyst/src/main/res/values-et-rEE/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-fa/strings.xml b/amethyst/src/main/res/values-fa/strings.xml index 0ca33446f..074235d56 100644 --- a/amethyst/src/main/res/values-fa/strings.xml +++ b/amethyst/src/main/res/values-fa/strings.xml @@ -116,6 +116,7 @@ آدرس آواتار آدرس بنر آدری وبسایت + ضمیرها آدرس لایتنینگ آدرس لایتنینگ(نقل شده) در گالری ذخیره کن @@ -183,6 +184,7 @@ بارگذاری حساب کاربری " :خطا در بارگیری پاسخ ها" دوباره تلاش کنید + هنوز هیچ اعلانی نیست. خبرنامه خالیست تازه‌سازی ساخته شد @@ -202,6 +204,8 @@ ترجمه از به ابتدا به نشان بده %1$s + گفتگوی عمومی درباره %1$s + انجمن عمومی درباره %1$s همیشه به ترجمه کن %1$s هرگز به ترجمه نکن %1$s آدرس ناستر @@ -351,7 +355,8 @@ آدرس لایتنینگ یا @User سرورهای رسانه سرورهای مورد علاقه خود برای بارگزاری رسانه را انتخاب کنید. - هیچ مجموعه سرور سفارشی ندارید. می توانید از لیست امتیست استفاده کنید، یا از لیست زیر اضافه کنید↓ + هیچ مجموعه سرور NIP-96 ندارید. می توانید از لیست امتیست استفاده کنید، یا از لیست زیر اضافه کنید↓ + هیچ سروری برای Blossom ندارید. می توانید از لیست امتیست استفاده کنید، یا از لیست زیر اضافه کنید↓ سرورهای داخلی رسانه لیست پیش فرض امتیست. می توانید تک به تک یا تمام لیست را اضافه کنید. استفاده از لیست پیش فرض @@ -392,7 +397,7 @@ رله های پیام خصوصی تور را برای ارسال و دریافت پیام خصوصی الزامی کن رله های غیر معتمد - تور را برای رله های ارسال / دریافت الزامی کن + تور را برای رله های ارسال/دریافت الزامی کن رله های معتمد تور را برای تمام رله های لیست الزامی کن تصاویر نمایه @@ -545,7 +550,11 @@ باز ارسال زپ ها به: کلاینت هایی که این قابلیت را دارند زپ ها را به جای شما به LNAddress یا نمایه کاربر زیر می فرستند افشای مکان به عنوان - یک از مکان شما به این بادداشت می افزاید. دیگران خواهند دانست که شما در فاصله 5 کیلومتری (3 مایلی) این مکان هستید. + یک هش جغرافیایی از مکان شما به این یادداشت می افزاید. دیگران خواهند دانست که شما در فاصله 5 کیلومتری (3 مایلی) این مکان هستید. + پست اختصاصی مکان + فقط دنبال کنندگان این مکان می توانند آن را ببینند. دنبال کنندگان معمولی شما آن را نمی بینند. + بارگیری مکان + اجازه ثبت مکان داده نشده پیش از نمایش محتوای شما، هشدار محتوای حساس به آن می افزاید. این برای هرگونه محتوای نامناسب برای محیط کار یا هر محتوای دیگری که برخی ممکن است توهین آمیز یا مشوش کننده بدانند خوب است. ویژگی جدید برای فعال سازی این حالت لازم است اماتیست یک پیغام NIP-17 بفرستد (پیغام های GiftWrapped, Sealed Direct and Group). مطمئن شوید که گیرنده از کلاینتی سازگاز استفاده می کند. @@ -764,10 +773,10 @@ کلید عمومی را به شکل کد QR نشان بده آدرس نامعتبر امتیست آدرسی برای باز کردن دریافت کرد ولی آدرس نامعتبر بود: %1$s - رله های صندوق پیام خصوصی + رله های صندوق دریافت پیام خصوصی رله ها: %1$s استفاده از رله های معمولی - تنظیم رله های صندوق ورودی خصوصی + تنظیم رله های صندوق دریافت خصوصی با این تنظیمات همه می توانند بدانند که هنگام ارسال پیام به شما از کدام رله ها استفاده کنند. بدون این تنظیمات ممکن است برخی پیام ها را از دست بدهید. گزینه های خوب: - inbox.nostr.wine (پولی) @@ -777,11 +786,103 @@ - inbox.nostr.wine (پولی) - auth.nostr1.com (رایگان) - you.nostr1.com (رله شخصی - پولی) + از ۱ تا ۳ رله به عنوان رله خصوصی وارد کنید. رله های صندوق دربافت پیام های خصوصی می بایست هر پیامی از هرکس را بپذیرند، ولی تنها به شما اجازه بارگیری پیام ها را می دهند. هم اکنون تنظیم شود رله های جستجو تنظیم رله های جستجو + ساخت لیستی از رله های مخصوص جستجو و تگ کردن کاربر باعث بهبود این نتایج خواهد شد. + بین ۱-۳ رله بیافزایید تا هنگام جستجوی محتوا یا تگ کردن کاربران از آنها استفاده شود. حتما رله های انتخابی باید NIP-50 را پیاده‌سازی کرده باشند. + انتخاب های مناسب: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + تنظیمات رله + رله های خانه/ صندوق خروجی عمومی + این نوع از رله تمام محتوای شما را ذخریه می کند. امتیست پست های شما را اینجا می فرستد و دیگران از این رله ها برای یافتن محتوای شما استفاده می کنند. بین ۱-۳ رله وارد کنید. این رله ها ممکن است رله های شخصی، پولی، یا عمومی باشند. + رله های صندوق دریافت عمومی + این نوع رله تمام پاسخ ها، نظرات، پسندها، و زپ های یادداشت های شما را دریافت می کند. این ها می توانند رله های پولی یا رایگان باشند. محدودیتی که گرداننده رله تعیین می کند ممکن است اعلان هایی را که دریافت می کنید محدود کند. این می تواند اثر خوب یا بد داشته باشد. مثلا اگر تحت حمله کامنت اسپم باشید، رله پولی می تواند این اسپم را فیلتر کند. بین ۱-۳ رله وارد کنید. + رله های صندوق دریافت پیام خصوصی + بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: + - inbox.nostr.wine (پولی) +- auth.nostr1.com (رایگان) +- you.nostr1.com (رله پولی یا شخصی) + رله های خصوصی خانه + بین ۱-۳ رله وارد کنید برای ذخیره رویدادهایی که هیچ کس دیگر نمی بیند ، به عنوان مثال پیش نویس های شما و یا تنظیمات اپلیکیشن. در حالت ایده آل، این رله ها یا محلی هستند یا پیش از بارگیری محتوای کاربر به احراز هویت نیاز دارند. + رله های کلی + امتیست از این رله ها استفاده می کند تا پست ها را برایتان بارگیری کند. + رله های پیشنهادی + رله های روبرو را به لیست رله های کلی خود بیافزایید تا پست های کاربران لیست شده را دریافت کنید. + رله های جستجو + لیست رله هایی که هنگام جستجوی محتوا یا کاربران مورد استفاده قرار می گیرند. اگر انتخابی در موجود نباشد، تگ کردن یا جستجو کار نخواهد کرد. حتما مطمئن شوید این رله ها را NIP-50 پیاده‌سازی کرده باشند. + رله های محلی + لیست رله هایی که در این دستگاه کار می کنند. به توسعه دهندگان رپ دهید! + هدیه شما به ما برای تاثیرگذاری کمک می کند. هر ساتوشی به حساب می آید! + اکنون کمک کنید + تقدیم از طرف: + این نسخه نرم‌افزار پیشکش از: + نسخه %1$s + متشکریم! + حداکثر + نوشتن محدود + انشعاب شده از + انشعاب + مخزن گیت: %1$s + وب: + نسخه برداری: + OTS: %1$s + اثبات مهر زمان + مدرکی وجود دارد که ثابت کند این پست پیش از %1$s امضا شده است. این مدرک در بلاکچین بیتکوین در آن تاریخ و زمان ثبت شده است. + ویرایش نوشته + پیشنهاد برای بهبود یک نوشته + خلاصه تغییرات + اصلاحات سریع… + پذیرش پیشنهاد + بارگيری + روشن کردن متن متن خاموش + پیام مهروموم شده خاموش است. برای روشن کردن کلیک کنید. + پیام مهروموم شده روشن است. برای خاموش کردن کلیک کنید. ارسال + نام کاربری را به شکل صوتی بخوان. + Pushpin + کد QR را اسکن کنید + رفتن به البی، ارائه دهنده کیف پول شخص ثالث. + پاسخ به پیش نویس یادداشت ممکن نیست. + نقل قول از پیش نویس یادداشت ممکن نیست. + واکنش به پیش نویس یادداشت ممکن نیست. + زپ زدن به پیش نویس یادداشت ممکن نیست. پیش نویس یادداشت + از پیام + جستجوی اپلیکیشن + درخواست شغل ارسال شد، در انتظار پاسخ + درخواست شغل از DVM + درخواست پرداخت ارسال شد، در انتظار تایید کیف پول + در انتظار تایید پرداخت یا ارسال نتیجه توسط DVM + درخواست بد - سرور نمی تواند یا نمی خواهد این درخواست را پردازش کند. + غیرمجاز - کاربر دارای مشخصات معتبر احراز هویت شده نیست + پرداخت الزامی - سرور برای تکمیل درخواست به پرداخت دارد. + ممنوع - کاربر مجاز به انجام این درخواست نیست. + یافت نشد - سرور نتوانست آدرس درخواست شده را بیابد. + این روش مجاز نیست - سرور روش درخواست را پیشتیبانی می کند ولی مرجع هدف را نه. + غیرقابل قبول - سرور نتوانست محتوایی مناسب درخواست بیابد. + احراز هویت الزامی پروکسی - کاربر اعتبارات احراز هویت معتبر ندارد + زمان درخواست سپری شد - زمان درخواست در انتظار شخصی دیگر تمام شد + تعارض - سرور نتوانست درخواست را انجام دهد چون در مرجع تعارض وجود دارد + نیست - محتوای درخواست شده برای همیشه از سرور پاک شده و بازیابی نخواهد شد + الزام طول - سرور درخواست را رد کرد چون نیاز به اندازه تعریف شده دارد + پیش شرط انجام نشد - پیش شرطی در سربرگ وجود دارد که سرور نمی تواند براورده کند + حجم زیادی - درخواست از محدودیت های تعریف شده سرور بزرگتر است و سرور آن را پردازش نمی کند + آدرس زیادی بلند - آدرس درخواست شده توسط کاربر زیادی برای پردازش سرور بلند است. + نوع رسانه پشتیبانی نشده - درخواست از نوعی رسانه استفاده می کند که توسط سرور پشتیبانی نمی شود + بازه براورده نشد - سرور نتوانست مقدار مورد نظر در سربرگ بازه را براورده کند. + انتظارات براورده نشد - سرور نتوانست انتظارات تعیین شده در سربرگ توقع درخواست را براورده کند + ارتقاء الزامی - سرور درخواست را با پروتکل فعلی پردازش نمی کند تا کلاینت پروتکل را ارتقاء دهد. + سرورهای NIP-96 + سرورهای Blossom + حذف همه + استک: + فایل تورنت + بارگيری + فایل باز نشد + هیچ اپ تورنتی برای باز کردن و بارگیری فایل نصب نیست. + لیستی را برای فیلتر خبرنامه انتخاب کنید + با قفل کردن دستگاه از حساب کاربری خارج شو diff --git a/amethyst/src/main/res/values-fo-rFO/strings.xml b/amethyst/src/main/res/values-fo-rFO/strings.xml index 66a570486..3ea04e700 100644 --- a/amethyst/src/main/res/values-fo-rFO/strings.xml +++ b/amethyst/src/main/res/values-fo-rFO/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 66a570486..3ea04e700 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml index 03eb6590d..2704899b4 100644 --- a/amethyst/src/main/res/values-fr/strings.xml +++ b/amethyst/src/main/res/values-fr/strings.xml @@ -116,6 +116,7 @@ URL de l\'avatar URL de la bannière URL de site + Pronoms Adresse LN URL LN (obsolete) Enregistrer dans la Galerie @@ -147,6 +148,8 @@ Adresse Lightning Copie l\'ID Nsec (votre mot de passe) dans le presse-papiers pour le sauvegarder Copier la clé secrète dans le presse-papiers + Afficher le QR code de la clé privée + Afficher le QR code de la clé privée chiffrée Copie la clé publique dans le presse-papiers pour la partager Copier la clé publique (NPub) dans le presse-papiers Envoyer un message direct @@ -183,6 +186,7 @@ Chargement du compte "Erreur lors du chargement des réponses : " Essayer à nouveau + Aucune notification pour le moment. Le flux est vide. Rafraîchir créé @@ -202,6 +206,8 @@ traduit de vers Montre en %1$s en premier + Discussion publique à propos d\' %1$s + Communauté publique à propos de %1$s Toujours traduire en %1$s Ne jamais traduire depuis %1$s Adresse Nostr @@ -332,10 +338,13 @@ Qu\'est-ce que cela signifie ? Ce contenu est le même depuis le message Ce contenu a changé. L\'auteur n\'a peut-être pas vu ou approuvé le changement + Ajouter un média Ajouter une Image Ajouter une Vidéo Ajouter un Document Ajouter au Message + Ajouter une légende + Mon adorable ami Description des contenus Un bateau bleu sur une plage de sable blanc au coucher du soleil Type de Zap @@ -349,15 +358,25 @@ Non-Zap Aucune trace sur Nostr, seulement sur le Lightning Serveur fichier + Choisissez un serveur vers lequel téléverser ce fichier Adresse LN ou @Utilisateur Serveurs média Définissez vos serveurs de téléversement de média préférés. - Vous n\'avez pas de serveur média personnalisé. Vous pouvez utiliser la liste d\'Amethyst, ou en ajouter un ci-dessous + Vous n\'avez pas de serveur NIP-96 défini. Vous pouvez utiliser la liste d\'Amethyst, ou en ajouter un ci-dessous ↓ + Vous n\'avez pas de serveur Blossom défini. Vous pouvez utiliser la liste d\'Amethyst, ou en ajouter un ci-dessous ↓ Serveurs média intégrés Liste par défaut d\'Amethyst. Vous pouvez les ajouter individuellement ou ajouter la liste. Utiliser la liste par défaut Ajouter un serveur média Supprimer le serveur média + Non commencé + Compression en cours + Téléversement en cours + Traitement en cours + Téléchargement en cours + Hachage + Terminé + Erreur Vos relais (NIP-95) Les fichiers sont hébergés par vos relais. Nouveau NIP: vérifiez s\'ils sont supportés Options de confidentialité @@ -547,6 +566,10 @@ Les clients compatibles transmettront des zaps sur l\'adresse LN ou le profil Utilisateur ci-dessous au lieu du vôtre. Révéler la localisation comme Ajoute un Geohash de votre emplacement au message. Le public saura que vous êtes à moins de 5km de l\'emplacement actuel + Post exclusif à la localisation + Seuls les abonnés à cette localisation le verront. Vos abonnés généraux ne le verront pas. + Chargement de la localisation + Pas d\'autorisations de localisation Ajoute un avertissement de contenu sensible avant de montrer votre contenu. C\'est idéal pour tout contenu NSFW ou contenu que certaines personnes peuvent trouver offensant ou dérangeant Nouvelle Fonctionnalité Pour activer ce mode, Amethyst doit envoyer un message NIP-17 (GiftWrapped, Sealed Direct et Group Messages). Le protocole NIP-17 est nouveau et la plupart des clients ne l\'ont pas encore mis en oeuvre. Assurez-vous que le destinataire utilise un client compatible. @@ -709,7 +732,9 @@ Erreur de téléversement : %1$s Le serveur n\'a pas fourni d\'URL après le téléversement Impossible de télécharger le média depuis le serveur + Impossible de vérifier le fichier téléchargé après le téléversement : %1$s Impossible de préparer le fichier local à téléverser: %1$s + Impossible de téléverser vers %1$s: %2$s Échec du téléversement: %1$s Échec de la suppression : %1$s Le média est trop volumineux pour NIP-95 @@ -825,6 +850,7 @@ Message scellé activé. Cliquer pour désactiver le message scellé Envoyer Écouter le nom d\'utilisateur + Punaise Scanner le QR code Accéder au fournisseur de portefeuille tiers Alby Il n\'est pas possible de répondre à un brouillon @@ -867,7 +893,12 @@ Stockage insuffisant - Le serveur n\'a pas assez d\'espace pour traiter la demande avec succès Boucle détectée - Le serveur détecte une boucle infinie lors du traitement de la requête Authentification réseau requise - Le client doit être authentifié pour accéder au réseau + Serveurs NIP-96 + Ajoutez autant de serveurs que vous le souhaitez. Vous pourrez choisir celui à utiliser plus tard lors du téléversement de votre image + Serveurs Blossom + Ajoutez autant de serveurs que vous le souhaitez. Vous pourrez choisir celui à utiliser plus tard lors du téléversement de votre image Ajouter un serveur NIP-96 + Ajouter un serveur Blossom Tout supprimer Êtes-vous sûr de vouloir supprimer tous les brouillons ? Pile : diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 262329009..829f75f3d 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -116,6 +116,7 @@ अवतारचित्र जालपता ध्वजचित्र जालपता जालस्थान पता + सर्वनाम लै॰जाल पता लै॰जाल पता (पुराना) चित्रालय में अभिलेखन करें @@ -147,6 +148,8 @@ लैटनिंग पता एनसेक॰ विभेदक (आपका गुप्त पारणशब्द) की अनुकृति करता है टाँकाफलक में सुरक्षित रखने के लिए गुप्त कुंचिका की अनुकृति करें टाँकाफलक में + निजी कुंचिका क्यूआर॰ क्रमचित्र दिखाएँ। + रहस्यीकृत निजी कुंचिका क्यूआर॰ क्रमचित्र दिखाएँ। ख्याप्य कुंचिका की अनुकृति करता है टाँकाफलक में बाँटने के लिए ख्याप्य कुंचिका (एनपुब॰) की अनुकृति करें टाँकाफलक में सीधा संदेश भेजें @@ -183,6 +186,7 @@ लेखा जानकारी प्राप्त की जा रही है "प्रतिवचनों को प्राप्त करने में अपक्रम : " पुनः प्रयास करें + अभी तक कोई सूचनाएँ नहीं। सूचनावली रिक्त है। नवीकरण बनाया गया @@ -202,6 +206,8 @@ इससे अनुवादित इस तक प्रथम %1$s में दिखाएँ + सार्वजनिक चर्चा %1$s के विषय पर + सार्वजनिक समूह %1$s विषय पर सर्वदा अनुवाद करें %1$s में कभी भी अनुवाद ना करें %1$s से नोस्ट्र पता @@ -332,10 +338,13 @@ इसका अर्थ क्या है? यह विषयवस्तु वैसे ही है जैसे पत्र प्रकाशन पर यह विषयवस्तु परिवर्तित हुआ है। हो सकता है लेखक ने परिवर्तन देखा नहीं अथवा अनुमति दिया नहीं। + चित्र चलचित्र जोडें चित्र जोडें चलचित्र जोडें पत्र जोडें संदेश में जोड दें + शीर्षक जोडें + मेरे प्रिय मित्र विषयवस्तु का विवरण एक नीला नाव श्वेत रेतीला तट पर सूर्यास्त पर ज्साप प्रकार @@ -349,15 +358,25 @@ ज्साप अतिरिक्त नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर अभिलेख सेवासंगणक + इस अभिलेख का आरोहण करने के लिए सेवासंगणक का चयन करें लै॰जाल पता अथवा @उपयोगकर्ता प्रसारसंगणक आपके प्रसारसंगणक आद्यताएँ स्थापित करें। - आपका कोई विशिष्ट प्रसारसंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा प्रसारसंगणक नीचे जोड सकते हैं ↓ + आपका कोई निप॰-९६ सेवासंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा नीचे एक जोड सकते हैं ↓ + आपका कोई ब्लोस्सम॰ प्रसारसंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा नीचे एक जोड सकते हैं ↓ अन्तर्निहित प्रसारसंगणक अमेथिस्त की मूलविकल्प सूची। आप एक एक करके जोड सकते हैं अथवा सूची जोड सकते हैं। मूलविकल्प सूची का प्रयोग करें प्रसारसंगणक जोडें प्रसारसंगणक मिटाएँ + आरम्भ नहीं हुआ + संक्षिप्तीकरण चल रहा है + आरोहण चल रहा है + प्रक्रिया चल रही है + अवरोहण चल रहा है + विभेदकोत्पादन प्रक्रिया चल रही है + हो गया + अपक्रम आपके पुनःप्रसारक (निप॰-९५) अभिलेख आपके पुनःप्रसारक द्वारा रखे जाते हैं। नया निप॰: जाँच करें यदि वे अवलम्बन करते हैं गोपनीयता विकल्प @@ -371,6 +390,7 @@ नहीं अनुचरण सूची सभी अनुचरित + मेरे आसपास वैश्विक मौन सूची मूलविकल्प द्वार ९०५० है @@ -540,11 +560,16 @@ नोस्ट्र पता का जाँच चल रहा है सब का चयन / अचयन करें मूलविकल्प + मूलविकल्प पुनःस्थापित करें पुनःप्रसारक चुनें आगे बढने के लिए ज्साप आगे भेजें इनको : अवलम्बन करनेवाले ग्राहक ज्सापों को आगे भेजेंगे लै॰जाल पता को अथवा नीचे के उपयोगकर्ता परिचय को तथा आपके तक नहीं स्थान अनावृत करें ऐसे आपका भूगोलिक स्थान विभेदक जोडता है पत्र में। जनता जान जाएगी कि आप वर्तमान स्थान से ५ कि॰मे॰ (३ मी॰) की दूरी के अन्दर हैं + स्थल विशेष पत्र + स्थल के अनुचर ही देखेंगे। आपके सामान्य अनुचर नहीं देखेंगे। + स्थान प्राप्त किया जा रहा है + स्थान प्राप्त करने की अनुमति नहीं आपके विषयवस्तु दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है। यह आदर्श है किसी कार्यालय अनुचित विषयवस्तु के लिए अथवा जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है नयी सुविधा इस कार्यशैली सक्षम करने के लिए अमेथिस्ट के द्वारा निप॰-१७ संदेश (उपहारकोषयुक्त, आच्छादित सीधा तथा झुण्ड संदेश) भेजना पडेगा। यह निप॰-१७ नया है तथा अनेक ग्राहक इसे कार्यान्वित किया नहीं अब तक। सुनिश्चित करें कि प्राप्तकर्ता एक अनुकूल ग्राहक का प्रयोग कर रहे हैं। @@ -707,7 +732,9 @@ आरोहण अपक्रम : %1$s सेवासंगणक ने आरोहण पश्चात जालपता नहीं दिया सेवासंगणक से आरोहणकृत चित्र चलचित्र का अवरोहण नहीं कर पाए + आरोहण पश्चात अवरोहित अभिलेख की जाँच नहीं हो सकी : %1$s स्थानीय अभिलेख अनुकूल नहीं बना सके आरोहण के लिए : %1$s + %1$s पर आरोहण असफल : %2$s आरोहण असफल : %1$s मिटाने में असफल : %1$s अभिलेख निप॰-९५ के लिए बहुत बडा है @@ -769,6 +796,7 @@ आपके निजी आगतपेटिका पुनःप्रसारकों की स्थापना करें यह स्थापना विकल्प सब को सूचित करता है आपको सन्देश भेजने के लिए कौनसे पुनःप्रसारकों का प्रयोग करना चाहिए। इनके बिना आप कुछ सन्देश प्राप्त नहीं कर पाएँगे। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - auth.nostr1.com (शुल्करहित)\n - you.nostr1.com (निजी पुनःप्रसारक - सशुल्क) + ये अच्छे विकल्प हैं :\n - auth.nostr1.com (शुल्करहित)\n - inbox.nostr.wine (सशुल्क)\n - relay.0xchat.com (शुल्करहित) निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को सब से सन्देश स्वीकारना चाहिए पर उनका अवरोहण करने की अनुमति केवल आपको देना चाहिए। अभी स्थापना करें खोज पुनःप्रसारक @@ -822,6 +850,7 @@ समावृत संदेश सक्रिय। समावृत संदेश निष्क्रिय करने के लिए टाँकें भेजें उपयोगकर्ता नाम को ध्वनि के रूप में चलाएँ + घुण्डी क्यूआर॰ क्रमचित्र परखें तृतीय पक्ष धनकोष प्रदाता आल्बी तक जाएँ एक टीका पाण्डुलिपि को उत्तर नहीं दे सकते @@ -864,7 +893,12 @@ स्मृतिस्थान का अभाव - सेवासंगणक में पर्याप्त स्मृतिस्थान उपलब्ध नहीं अनुरोध पर सफलतापूर्वक काम करने के लिए क्रमचक्र दृष्ट - सेवासंगणक को अनन्त क्रमचक्र का पता चला अनुरोध पर काम करते हुए जाल प्रमाणीकरण आवश्यक - जाल उपलब्ध होने के लिए ग्राहक का प्रमाणीकरण अनिवार्य + निप॰-९६ सेवासंगणक + सेवासंगणक जितना चाहें जोडें। किस संगणक का उपयोग करना है उसका चयन कर सकते हैं चित्र का आरोहण करते समय + ब्लोस्सम॰ प्रसारसंगणक + सेवासंगणक जितना चाहें जोडें। किस संगणक का उपयोग करना है उसका चयन कर सकते हैं चित्र का आरोहण करते समय निप॰-९६ सेवासंगणक जोडें + ब्लोस्सम॰ प्रसारसंगणक जोडें सब मिटाएँ क्या आप निश्चित रूप से सभी पाण्डुलिपियाँ मिटाना चाहते हैं। चिति : diff --git a/amethyst/src/main/res/values-hu/strings.xml b/amethyst/src/main/res/values-hu/strings.xml index dcb4adce7..114187355 100644 --- a/amethyst/src/main/res/values-hu/strings.xml +++ b/amethyst/src/main/res/values-hu/strings.xml @@ -1,166 +1,169 @@ - Mutass a QR kódra - QR kód megjelenítése - Profil kép - Profilkép - QR kód beolvasása - Mutasd - Ez a bejegyzés el lett rejtve, mert említ rejtett felhasználóidat vagy szavaidat - A bejegyzést nem megfelelőként jelölte meg - Bejegyzés nem található + Fókuszálás a QR-kódra + QR-kód megjelenítése + Profilkép + Az Ön profilképe + QR-kód beolvasása + Megjelenítés mindenképp + Ez a hozzászólás el lett rejtve, mert az Ön rejtett felhasználóit vagy szavait említi + A hozzászólás el lett némítva vagy be lett jelentve általa: + Az esemény épp betöltődik vagy nem található az átjátszólistában 👀 - Csatorna Kép + Csatornakép A hivatkozott esemény nem található - Az üzenet dekódolása sikertelen - Csoport Kép + Nem sikerült visszafejteni az üzenetet + Csoportkép Szókimondó tartalom - Spam - Ebből a csomópontból érkező spam események száma - Megszemélyesítés + Kéretlen tartalom + Az erről az átjátszóról érkező kéretlen tartalmú események száma + Profilutánzás Illegális viselkedés Egyéb Ismeretlen - Csomópont Ikon - Ismeretlen Szerző - Szöveg Másolása - A felhasználó PubKulcs másolása - A bejegyzés azonosítójának másolása - Közvetít - Időbélyegezni + Átjátszóikon + Ismeretlen szerző + Szöveg másolása + Szerző azonosítójának másolása + Bejegyzés azonosítójának másolása + Közvetítés + Időbélyegzés Időbélyeg: függőben lévő megerősítések - OTS: Függőben - Törlés Kérése - Tiltás és Jelentés - - Spam / Csalás bejelentés - Megszemélyesítés bejelentés - Szókimondó tartalom bejelentés - Illegális viselkedés bejelentés - Vírus bejelentése - Jelentés Mod - Vírus + OTS: függőben + Törlés kérése + Letiltás / Bejelentés + + Kéretlen tartalom vagy csalás bejelentése + Profilutánzás bejelentése + Szókimondó tartalom bejelentése + Illegális viselkedés bejelentése + Malware bejelentése + Mod bejelentése + Malware Mod - Jelentkezzen be privát kulccsal, hogy válaszolni tudjon - Jelentkezzen be privát kulccsal a bejegyzések megosztásához - Jelentkezzen be privát kulccsal a bejegyzések kedveléséhez - Nincs Zap összeg beállítva. Nyomja meg hosszan a változtatáshoz - Jelentkezzen be privát kulccsal, hogy Zaps-t küldhessen - Jelentkezz be a privát kulccsal, hogy követni tudd - Jelentkezz be a privát kulccsal, hogy őt kikövesd - Nyilvános kulcsot használsz, és a nyilvános kulcsok csak olvasásra adnak lehetőséget. Jelentkezz be a privát kulccsoddal egy szó vagy mondat elrejtéséhez - Nyilvános kulcsot használsz, és a nyilvános kulcsok csak olvasásra adnak lehetőséget. Jelentkezz be a privát kulccsoddal egy szó vagy mondat megjelenítéséhez - Zap-ek + Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy válaszolni tudjon + Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy meg tudja tolni a bejegyzéseket + Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. Jelentkezzen be a privát kulccsal a hozzászólások kedveléséhez + Nincs beállítva Zap-összeg. Koppintson hosszan a beállításhoz + Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatók a bejegyzések. Jelentkezzen be a privát kulcsával, hogy Zap-et tudjon küldeni + Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy követni tudjon embereket + Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy ki tudja követni az embereket, akiket követ + Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy egy szót vagy mondat el tudjon rejteni + Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy egy szót vagy mondat meg tudjon jeleníteni + Zap Megtekintések száma - Megosztás - Megosztva + Megtolás + Megtolva szerkesztve - szerkesztés #%1$s + #%1$s szerkesztése eredeti - Idézet - Tükrözés + Idézés + Elágaztatás Szerkesztési javaslat - Új összeg sats-ban - Hozzáad - "válasz erre " + Új összeg satoshiban + Hozzáadás + "válasz erre: " " és " "csatornában " - Profil Banner + Profilplakát Sikeres fizetés Hiba a hibaüzenet elemzésekor - " Követek" - " Követők" + " Követett" + " Követő" Profil - Biztonsági filterek + Biztonsági szűrők Kijelentkezés - Mutass még - Lightning Számla + Továbbiak megjelenítése + Lightning-számla Fizetés - Lightning Jatt - Üzenet a Fogadónak + Lightning-borravaló + Bejegyzés a kedvezményezettnek Nagyon szépen köszönöm! - Összeg Sats-ban - Sats küldése - "Hiba a következő előnézetében %1$s : %2$s" - "Kártyakép előnézete %1$s" - Új Csatorna - Csatorna neve - Az én fantasztikus csoportom - Kép URL-je + Összeg satoshiban + Satoshi küldése + "Hiba a következő előnézet elemzésekor %1$s : %2$s" + "Kártyakép előnézete a következőhöz: %1$s" + Új csatorna + Csatornanév + A nagyszerű csoportom + Kép webcíme Leírás - "Rólunk.. " - Mire gondolsz? - Küldés + "Névjegy… " + Mi jár a fejében? + Közzététel Mentés - Létrehoz - Törlés + Létrehozás + Mégse Nem sikerült feltölteni a képet - Csomópont Címe - Hozzászólások - Bájtok + Az átjátszó címe + Bejegyzések + Bájt Hibák Ebben a munkamenetben lévő kapcsolati hibák száma - Hírfolyamod - Privát Üzenetek listája - Publikus Chat listája - Globális lista - Keresettek listája - Csomópont hozzáadása - Megjelenítendő Név - Az én megjelenített névem - Király McStrucc - Üdvözlünk Strucc! + Fő hírfolyam + Privát üzenet-hírfolyam + Nyilvános üzenet-hírfolyam + Globális hírfolyam + Keresési hírfolyam + Egy átjátszó hozzáadása + Megjelenítendő név + Saját megjelenítendő név + Strucc McNagyszerű + Üdvözöljük! Felhasználónév - Felhasználónevem - Rólam - Avatarom URL-je - Bannerem URL-je - Oldalam URL-je - LN Cím - LN URL (elavult) + Saját felhasználónév + Névjegy + Profilkép webcíme + Profilplakát webcíme + Weboldal webcíme + Megszólítás + LN-cím + LN-webcím (elavult) Mentés a galériába - A kép a galériába mentve - Nem sikerült menteni a képet - A videó a telefon galériájába mentve - Nem sikerült a videót menteni + Kép elmentve a képgalériába + Nem sikerült elmenteni a képet + Videó elmentve a videógalériába + Nem sikerült elmenteni a videót Kép feltöltése Feltöltés… - A felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással - "itt tudsz válaszolni.. " - Megosztáshoz a bejegyzés azonosítót a vágólapra másolja - Bejegyzés azonosító vágólapra másolása + A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel + "Válasz írása… " + A bejegyzés azonosítóját a vágólapra másolja a Nostr-ban való megosztáshoz + Csatorna-azonosító (bejegyzés) másolása a vágólapra Szerkeszti a csatorna metaadatait Csatlakozás Ismert - Új kérések + Új felkérések Letiltott felhasználók - Bejegyzések + Új bejegyzések Beszélgetések Bejegyzések Válaszok Galéria - "Követek" - "Jelentések" - Több beállítás - " Csomópontok" + "Követett" + "Bejelentés" + További beállítások + " Átjátszó" Weboldal - Lightning Cím - Biztonsági mentés céljából másolja az Nsec azonosítót (jelszavadat) a vágólapra - Másolja a titkos kulcsot a vágólapra - Megosztáshoz a nyilvános kulcsot a vágólapra másolja - Másolja a nyilvános kulcsot (NPub) a vágólapra + Lightning-cím + Az Nsec azonosítót (az Ön „jelszavát”) a vágólapra másolja biztonsági mentés céljából + Titkos kulcs másolása a vágólapra + Privát kulcs megjelenítése QR-kódként + Jelszóval védett privát kulcs megjelenítése QR-kódként + Nyilvános kulcs másolása a vágólapra a megosztáshoz + Nyilvános kulcs (NPub) másolása a vágólapra Közvetlen üzenet küldése - Szerkeszti a felhasználó metaadatait + Felhasználó metaadatainak szerkesztése Követés - Visszakövetem - Tiltás feloldása + Viszontkövetés + Letiltás megszüntetése Felhasználói azonosító másolása - Felhasználó feloldása - "npub, hex, felhasználónév " - Törlés - App Logó - nsec / npub / hex privát kulcs - jelszót a kulcs kinyitásához + Felhasználó letiltásának megszüntetése + "npub, felhasználónév, szöveg" + Kiürítés + Alkalmazáslogó + nsec… vagy npub… + Jelszó a kulcs kinyitásához Jelszó megjelenítése Jelszó elrejtése Érvénytelen kulcs @@ -169,273 +172,291 @@ használati feltételeket A feltételek elfogadása szükséges Jelszó megadása kötelező - Kulcs szükséges + Kulcs megadása kötelező Név megadása kötelező Bejelentkezés Regisztráció Fiók létrehozása - Hogyan szólíthatunk? - Nincs Nostr fiókod? - Van már Nostr-fiókod? + Hogyan szólíthatjuk Önt? + Még nincs Nostr-fiókja? + Már van Nostr-fiókja? Új fiók létrehozása - Új kulcs létrehozása - Lista betöltése - Fiók betöltése + Új kulcs előállítása + Hírfolyam betöltése… + Fiók betöltése… "Hiba a válaszok betöltésekor: " - Próbáld újra - A lista üres. + Próbálja újra + Még nincsenek értesítések. + A hírfolyam üres. Frissítés - létrehozta - leírásával + létrehozva + a következő leírásával: és kép - megváltoztatta a chat nevét - leírását erre - és a képet erre - Kilépés - Kikövetem + megváltoztatta a csevegés nevét erre: + leírását erre: + és a képet erre: + Elhagyás + Követés megszüntetése Csatorna létrehozva - "A csatornainformáció a következőre módosult" - Publikus Chat - beérkezett hozzászólások + "A csatornainformáció a következőre módosult:" + Nyilvános csevegés + fogadott bejegyzések Eltávolítás Automatikus - fordítás erről + lefordítva erről a nyelvről: erre - Először %1$s nyelven - Mindig fordítsa le %1$s-ra - Soha ne fordíts %1$s-ról - Nostr Cím + Megjelenítés először %1$s nyelven + Nyilvános csevegés témája: %1$s + Nyilvános közösség témája: %1$s + Mindig fordítsa le erre: %1$s + Soha ne fordítsa le erről: %1$s + Nostr-cím soha most ó p n Meztelenség - Trágárság / Gyűlöletkeltő beszéd - Gyűlöletbeszéd jelentése - Meztelenség/pornó jelentése - egyebek + Trágárság / Gyűlöletbeszéd + Gyűlöletbeszéd bejelentése + Meztelenség / Pornó bejelentése + egyéb Az összes ismert megjelölése olvasottként - Az összes új megjelölése olvasottként + Az összes új üzenet megjelölése olvasottként Összes megjelölése olvasottként - Biztonsági Kulcsok + Biztonsági mentési kulcsok ## Kulcs- és biztonsági mentési tippek - \n\nFiókodat titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-el kezdődik. Bárki, aki a Te titkos kulcsodhoz hozzáfér, az a személyazonosságod használatával bármilyen tartalmat közzétehet. - \n\n- **Ne** helyezd el titkos kulcsodat olyan webhelyen vagy szoftverben, amelyben nem bízol. - \n- Az Amethyst fejlesztők a titkos kulcsodat **soha** nem fogják elkérni. - \n- A fiók-helyreállításhoz, titkos kulcsodról **mindig** készíts biztonságos biztonsági másolatot. Javasoljuk a jelszókezelő használatát. + \n\nFiókját egy titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-vel kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet. + \n\n- **Ne** helyezze el a titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik. + \n- Az Amethyst fejlesztői az Ön titkos kulcsát **soha** nem fogják Öntől elkérni. + \n- A fiók-helyreállításának érdekében, a titkos kulcsáról **mindig** készítsen biztonsági másolatot. Javasoljuk egy jelszókezelő használatát. + + A nagyobb biztonság érdekében titkosítsa egy jelszóval. A jelszóval védett kulcs **ncryptsec1**-vel kezdődik, és a jelszava nélkül nem használható. + \n\nHa elveszíti a jelszavát, nem tudja visszaállítani a kulcsát. - A nagyobb biztonság érdekében a kulcsot jelszóval titkosíthatod. Ez a kulcs **ncryptsec1**-val kezdődik, és a jelszavad nélkül nem használható. - \n\nHa elveszíted a jelszavát, nem tudod a kulcsodat visszaállítani. Nem sikerült a privát kulcsot titkosítani A titkos kulcs (nsec) a vágólapra másolva - A titkos kulcsom másolása - Titkosítsd és másold a titkos kulcsomat - Hitelesítés nem sikerült - A telefon tulajdonosát a biometrikus adatokkal nem sikerült hitelesíteni - A telefon tulajdonosát a biometrikus adatokkal nem sikerült hitelesíteni. Hiba: %1$s + Saját titkos kulcs másolása + Saját titkos kulcs titkosítása és másolása + Nem sikerült hitelesíteni + A biometrika nem tudta hitelesíteni a telefon tulajdonosát + A biometrika nem tudta hitelesíteni a telefon tulajdonosát. Hiba: %1$s Hiba - "Készítette %1$s" - "Kitűző %1$s" - Új Jelvényt kapott - Kitüntetésben részesült - Bejegyzés vágólapra helyezve - A szerző @npub-ja vágólapra helyezve - A bejegyzés azonosítója (@note1) vágólapra helyezve - Szöveg Kijelölése - "<Nem sikerült a privát üzenetet visszafejteni>\n\nTéged egy privát/titkosított beszélgetésben %1$s és %2$s idéztek." - Új Fiók Hozzáadása + "Létrehozta: %1$s" + "Kitűző %1$s számára" + Ön egy új kitűzőt kapott + Kitűzőben részesült + Bejegyzés szövege a vágólapra másolva + A szerző @npub-ja a vágólapra másolva + A bejegyzés-azonosító (@note1) a vágólapra másolva + Szöveg kijelölése + "<Nem sikerült a privát üzenetet visszafejteni>\n\nÖnt megemlítették egy privát/titkosított beszélgetésben %1$s és %2$s között." + Új fiók hozzáadása Fiókok - Fiók Kiválasztása - Új Fiók Hozzáadása + Fiók kiválasztása + Új fiók hozzáadása Aktív fiók - Tartalmaz privát kulcsot - Csak olvasásra, nincs privát kulcs + Privát kulcsot tartalmaz + Csak olvasás, nem privát kulcs Vissza - Kiválaszt - Link megosztása + Kiválasztás + Böngésző-hivatkozás megosztása Megosztás - Szerző azonosító - Bejegyzés azonosító - Szöveg Másolása + Szerző-azonosító + Bejegyzés-azonosító + Szöveg másolása Törlés - Kikövetés - Követem - Törlés a Galériából - Távolítsa el ezt a médiát a galériából, később elolvashatod - Törlés Kérése - Az Amethyst kérni fogja, hogy töröljék a bejegyzésed azokról a csomópontokról, amelyekhez jelenleg csatlakozol. Nincs garancia arra, hogy a bejegyzésed ezekről a csomópontokról vagy más közvetítőkből (ahol tárolni lehet) véglegesen törlődik. - Tiltás + Követés megszüntetése + Követés + Törlés a galériából + Távolítsa el ezt a médiát a galériából, később újra hozzáadhatja + Törlés kérése + Az Amethyst kérni fogja, hogy a bejegyzését töröljék azokról az átjátszókról, amelyekhez jelenleg csatlakozik. Nem garantálható, hogy a bejegyzése véglegesen törlődik ezekről az átjátszókról, vagy más átjátszókról, ahol esetleg tárolva van. + Letiltás Törlés - Tiltás - Jelentés + Letiltás + Bejelentés Törlés - Ne mutasd újra - Spam vagy átverés + Ne jelenítse meg újra + Kéretlen tartalom vagy átverés Trágárság vagy gyűlöletkeltő magatartás - Rosszindulatú megszemélyesítés + Rosszindulatú profilutánzás Meztelenség vagy pornográf tartalom Illegális viselkedés - Vírus - Az alkalmazásban egy felhasználó letiltása minden hozzá köthető tartalmat elrejt. Jegyzeteid továbbra is nyilvánosan megtekinthetők, beleértve a letiltott személyek számára is. A letiltott felhasználók a Biztonsági szűrők felületen jelennek meg. - - Visszaélés jelentése - Minden közzétett jelentés nyilvánosan látható lesz. - Opcionálisan a jelentéséhez további kontextust addhat… - További kontextus - Ok - Válassz egy okot… - Jelentés Elküldése - Tiltás és Jelentés - Tiltás - Könyvjelzők + Malware + A felhasználó letiltásával elrejti annak bejegyzéseit az alkalmazásban. Az Ön bejegyzései továbbra is nyilvánosan megtekinthetők azok számára is, akiket Ön letiltott. A letiltott felhasználók a „Biztonsági szűrők” lapon jelennek meg. + + Visszaélés bejelentése + Minden közzétett bejelentés nyilvánosan látható lesz. + További részleteket adhat meg a bejelentéssel kapcsolatban (nem kötelező)… + További részletek + Indoklás + Válasszon egy indoklást… + Bejelentés közzététele + Letiltás és bejelentés + Letiltás + Könyvjelző Piszkozatok - Privát Könyvjelzők - Publikus Könyvjelzők - Hozzáadás a Privát Könyvjelzőimhez - Hozzáadás a Publikus Könyvjelzőimhez - Törlés a Privát Könyvjelzőimből - Törlés a Publikus Könyvjelzőimből - Wallet Connect Szolgáltatás - Engedélyezed a Nostr Secret-et, hogy az alkalmazás elhagyása nélkül Zap-ot küldjön. Tartsa a titkot biztonságban, és ha lehetséges, privát csomópontot használjon - Wallet Connect Publikus Kulcs - Wallet Connect Csomópont - Wallet Connect Titok - Titkos Kulcs Megjelenítése + Privát könyvjelzők + Nyilvános könyvjelzők + Hozzáadás a privát könyvjelzőkhöz + Hozzáadás a nyilvános könyvjelzőkhöz + Törlés a privát könyvjelzőkből + Törlés a nyilvános könyvjelzőkből + Wallet Connect szolgáltatás + Hitelesíti, hogy a Nostr Secret az alkalmazásból való kilépés nélkül fizessen a Zap-et. Tartsa biztonságban a titkot, és lehetőség szerint használjon privát átjátszót + Wallet Connect nyilvános kulcs + Wallet Connect átjátszó + Wallet Connect titok + Titkos kulcs megjelenítése nsec / hex privát kulcs - Hozzájárulás összege sats-ban - Szavazás Létrehozása - Szükséges mezők: - Zap-et kapják + Hozzájárulás összege satoshiban + Szavazás közzététele + Kötelező mezők: + Zap-kedvezményezettek Szavazás elsődleges leírása… - Szavazás %s megoszlása - Szavazás opcióinak leírása - Kiegészítő mezők: + %s. válaszlehetőség + Válaszlehetőség leírása + Nem kötelező mezők: Minimum Zap Maximum Zap - Konszenzus + Együttműködés (0–100)% Szavazás lezárása - napok + nap Nem lehet szavazni - A szavazásra már nem lehet új szavazatokat leadni - Zap összege + A szavazásra már nem lehet új szavazato(ka)t leadni + Zap-összeg Az ilyen típusú szavazásokon felhasználónként csak egy szavazat engedélyezett "%1$s esemény keresése" Nyilvános üzenet hozzáadása - Adj hozzá egy privát üzenetet - A számlához adj hozzá egy üzenetet - Köszönöm a kemény munkát! - Létrehoz és Hozzáad - A szavazás létrehozója sajátjára nem szavazhat. + Privát üzenet hozzáadása + Számlaüzenet hozzáadása + Köszönöm mindenkinek a munkáját! + Létrehozás és hozzáadás + A szavazások szerzői nem szavazhatnak a saját szavazásaikon. Mit jelent ez? - A kép a bejegyzés óta ugyanaz - A kép megváltozott. Lehet, hogy a szerző nem látta a változást - Kép Hozzáadása - Videó Hozzáadása - Dokumentum Hozzáadása - Létrehoz és Hozzáad - A tartalom leírása - Naplementekor egy kék csónak egy fehér homokos tengerparton - Zap Típusa - Zap típus minden opcióhoz - Publikus + Ez a tartalom nem változott a közzététel óta + Ez a tartalom megváltozott. Lehet, hogy a szerző nem látta vagy nem hagyta jóvá a változtatást + Média hozzáadása + Kép hozzáadása + Videó hozzáadása + Dokumentum hozzáadása + Hozzáadás az üzenethez + Képaláírás hozzáadása + Kedves barátom + Tartalmi leírás + Egy kék csónak a fehér homokos tengerparton naplementekor + Zap-típus + Zap-típus minden lehetőséghez + Nyilvános Mindenki láthatja a tranzakciót és az üzenetet Privát - A küldő és a fogadó láthatja egymást és az üzenetet is elolvashatja + A küldő és a kedvezményezett láthatja egymást és az üzenetet is elolvashatja Névtelen - A címzett és a nyilvánosság nem tudja ki küldte a fizetést + A kedvezményezett és a nyilvánosság nem tudja, hogy ki küldte a fizetést Nem Zap - Nostr-ban nyoma sincs, csak a Lightning-ben - Fájl Szerver - LnCím vagy @Felhasználó - Média Szerverek - Állítsd be a kívánt médiafeltöltő szervereket. - Nincs beállítva egyéni médiaszerver. Használhatod az Amethyst listáját, vagy alább hozzáadhatsz egyet ↓ - Beépített médiaszerverek - Az Amethist alapértelmezett listája. Hozzáadhatod őket egyenként, vagy hozzáadhatod az egész listát. - Az alapértelmezett lista használata - Médiaszerver hozzáadása - Médiaszerver törlése - Saját csomópontjaid (NIP-95) - A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel - Adatvédelmi Beállítások - Tor/Orbot beállítása - Csatlakozás a saját Orbot beállításod alapján + Nostr-ban nyoma sincs, csak a Lightning-ban + Fájlkiszolgáló + Válasszon ki egy kiszolgálót a fájl feltöltéséhez neki: + Ln-cím vagy @Felhasználó + Médiakiszolgálók + Állítsa be a kívánt médiafeltöltő-kiszolgálókat. + Nincsenek beállítva NIP-96 kiszolgálók. Használhatja az Amethyst listáját, vagy hozzáadhat egyet az alábbiakból ↓ + Nincsenek Blossom-kiszolgálók beállítva. Használhatja az Amethyst listáját, vagy hozzáadhat egyet az alábbiakból ↓ + Beépített médiakiszolgálók + Az Amethyst alapértelmezett listája. Hozzáadhatja őket egyenként, vagy hozzáadhatja a listát. + Alapértelmezett lista használata + Médiakiszolgáló hozzáadása + Médiakiszolgáló törlése + Nem kezdődött el + Tömörítés… + Feltöltés… + Feldolgozás… + Letöltés… + Hash-selés… + Kész + Hiba + Saját átjátszók (NIP-95) + A fájlokat a kiszolgálók tárolják. Új NIP: ellenőrizze, hogy támogatják-e + Adatvédelmi beállítások + Tor és Orbot beállítása + Kapcsolódás az Orbot beállításain keresztül Beállítás - Tor Beállítások - Lecsatlakozás a saját Orbot/Tor hálózatról? - Az adataid azonnal a hagyományos hálózaton lesz továbbítva + Tor beállítások + Kapcsolat bontása az Orbottal / Torral? + Az Ön adatai azonnal átkerülnek a normál hálózatra Igen Nem - Követek Lista - Mindenki akit követek + Követési lista + Követettek bejegyzései + Közelben lévők bejegyzései Globális - Némítottak listája - Alapértelmezett port a 9050 - ## Kapcsolódj a TOR-on keresztül az Orbot segítségével - \n\n1. Telepítsd az [Orbot-ot](https://play.google.com/store/apps/details?id=org.torproject.android) - \n2. Indítsd el az Orbot-ot - \n3. Az Orbot-ban ellenőrizd a Socks portokat. Az alapbeállítás 9050 - \n4. Ha szükséges az Orbot-ban változtasd meg a portot - \n5. Ezen a felületen állítsd be a Socks portot - \n6. Kattíts az Aktiválás gombra, hogy az Orbot-ot átjátszóként használd + Némítottak bejegyzései + Alapértelmezett port: 9050 + ## Kapcsolódás a TORon keresztül az Orbot segítségével + \n\n1. Telepítse az [Orbotot](https://play.google.com/store/apps/details?id=org.torproject.android) + \n2. Indítsa el az Orbotot + \n3. Ellenőrizze az Orbotban a Socks-portokat. Az alapbeállítás: 9050 + \n4. Ha szükséges az Orbotban változtassa meg a portot + \n5. Ezen a felületen állítsa be a Socks-portot + \n6. Kattintson az „Aktiválás” gombra, hogy az Orbotot proxyként használja - Orbot Socks portja - Aktív Tor motor - Használd a belső verziót vagy az Orbot-ot - Tor/Adatvédelmi Beállítások - Gyorsan módosítsd az összes alábbi beállítást - Onion Url/Csomópontok - Használd a Tor-t bármely .onion Url-hez - PÜ Csomópontok - Tor kényszerítése PÜ küldésére és fogadására - Megbízhatatlan Csomópontok - Tor kényszerítése a kimenő/bejövő üzenetek csomópontjaihoz - Megbízható Csomópontok - Tor kényszerítése a listákon szereplő összes csomópontra + Orbot Socks-portja + Aktív Tor-motor + Használja a beépített verziót vagy az Orbotot + Tor / Adatvédelmi előbeállítások + Az alábbi beállítások gyors módosítása + Onion-webcím / Átjátszók + Használja a Tor-t bármely .onion webcímhez + Közvetlen üzenetátjátszók + Tor kényszerítése a privát üzenetek küldéséhez és fogadásához + Megbízhatatlan átjátszók + Tor kényszerítése a kimenő és bejövő üzenetátjátszókhoz + Megbízható átjátszók + Tor kényszerítése a listákon szereplő összes átjátszóra Profilképek Tor kényszerítése profilképek betöltésekor - URL Előnézetek - Tor kényszerítése URL előnézetekor + Webcím-előnézetek + Tor kényszerítése webcím előnézetekor Képek Tor kényszerítése képek betöltésekor Videók Tor kényszerítése videók betöltésekor Pénzműveletek - Tor kényszerítések zep-re, lightning és cashu tranzakciókhoz - Nostr Cím Ellenőrzése - Tor kényszerítése a NIP-05 címek ellenőrzésekor - Média Feltöltések + Tor kényszerítése Zap küldésekor, lightning és cashu tranzakciókhoz + Nostr-cím hitelesítése + Tor kényszerítése a NIP-05 címek hitelesítésekor + Média-feltöltések Tor kényszerítése média feltöltésekor - Belső + Beépített Orbot - Ki + Kikapcsolva Alap Alapértelmezett - Minden kivéve Média - Teljes Adatvédelem + Összes kivéve a médiát + Teljes adatvédelem Egyéni - Tor használata ha a szerver megköveteli - IP-cím elrejtése a véletlenszerű csomópontok elől - IP-cím elrejtése mindentől, kivéve a képek- és videóknál - IP-cím elrejtése minden kapcsolatnál - Hozd létre a sajátodat - Érvénytelen Port szám - Használd az Orbot-ot - TOR/Orbot Lekapcsolása + Tor használata amikor a kiszolgáló megköveteli + IP-cím elrejtése a véletlenszerű átjátszók elől + IP-cím elrejtése mindentől, kivéve a képeknél és videóknál + IP-cím elrejtése az összes kapcsolatnál + Készítsen sajátot + Érvénytelen portszám + Orbot használata + Kapcsolat bontása a Torral / Orbottal Privát üzenetek - Értesítést küld, amikor privát üzeneted érkezik - Zap-et kaptál - Értesítést küld, amikor valaki neked Zap-et küldött - %1$s sats - %1$s -tól - %1$s -ért + Értesítés, ha privát üzenet érkezik + Zap-et kapott + Értesítés, amikor valaki Zap-et küld Önnek + %1$s satoshi + Tőle: %1$s + neki: %1$s Értesítés: - Csatlakozz a beszélgetéshez - Felhasználó vagy Csoport azonosító + Csatlakozás a beszélgetéshez + Felhasználó- vagy csoport-azonosító npub, nevent vagy hex - Létrehoz + Létrehozás Csatlakozás Ma Tartalomra vonatkozó figyelmeztetés @@ -443,232 +464,236 @@ Az érzékeny tartalmat mindig rejtse el Az érzékeny tartalmat mindig jelenítse meg A tartalomra vonatkozó figyelmeztetéseket mindig jelenítse meg - Ajánlottak: - Idegenektől SPAM szűrése - Figyelmeztessen ha a bejegyzések azok által akiket követek jelentve vannak - Új reakció szimbólum - Nincs reakció típus kiválasztva. Nyomd Hosszan a változtatáshoz - ZapGyűjtés - Add meg a cél sats mennyiséget, amit ezzel a posztal el szeretnél érni. Azok a kliensek amik kompatibilisek, egy folyamatjelző csíkot fognak megjeleníteni, hogy adakozásra ösztönözzenek - Cél mennyiség sats-ban - A ZapGyűjtés %1$s-nál. %2$s sats kell a célig - Olvassás a csomópontból - Írás a csomópontra - Ehhez a csomóponthoz küldött bájtok mennyisége, beleértve a szűrőket és az eseményeket - Ebből a csomópontból fogadott bájtok mennyisége, beleértve a szűrőket és az eseményeket - Hiba történt a közvetítő információinak lekérése közben a %1$s -ról + Ajánlott alkalmazások: + Az idegenektől érkező kéretlen tartalmak szűrése + Figyelmeztessen amikor a bejegyzések jelentve vannak azok által akiket követek + Új reakció-szimbólum + A felhasználó számára nincsenek előre kiválasztott reakciótípusok. Hosszan nyomja meg a szív gombot a módosításhoz + Zap-gyűjtés + Hozzáadja a bejegyzéshez a satoshi célösszeget, hogy megemelje a bejegyzést. Az ezt támogató kliensek ezt egy előrehaladási sávval jeleníthetik meg, hogy adományozásra ösztönözzenek + Célösszeg satoshiban + A Zap-gyűjtés jelenleg: %1$s. %2$s satoshi kell még a célig + Olvasás az átjátszóról + Írás az átjátszóra + Az átjátszónak küldött bájt-mennyiség, beleértve a szűrőket és eseményeket is + Az átjátszótól kapott bájt-mennyiség, beleértve a szűrőket és eseményeket is + Hiba lépett fel, amikor megpróbálta lekérni az átjátszó-információt innen: %1$s Tulajdonos Verzió Szoftver Kapcsolat Támogatott NIP-ek - Csatlakozás díja - Fizetési cím - Korlátok + Csatlakozási díj + Fizetési webcím + Korlátozások Országok Nyelvek Címkék - Szabályzat - Hibák és megjegyzések ettől a csomóponttól - Üzenet hossza - Feliratkozások - Filterek - Feliratkozás azonosítójának hossza + Közzétételi szabályzat + Hibák és megjegyzések ettől az átjátszótól + Üzenethossz + Előfizetések + Szűrők + Előfizetés azonosítójának hossza Minimális előtag Maximális eseménycímkék - Tartalom hossza + Tartalomhossz Minimális PoW - Azonosítás + Hitelesítés Fizetés - Cashu Token + Cashu token Beváltás - Küldje a Zap tárcába - Nyissa meg a Cashu tárcában + Küldés ide: Zap Wallet + Megnyitás a Cashu pénztárcában Token másolása - Nincs Lightning Cím beállítva + Nincs beállítva Lightning-cím Token a vágólapra másolva ÉLŐ - NINCS ADÁS - VÉGE + OFFLINE + VÉGET ÉRT ÜTEMEZETT Az élő közvetítés offline - Az élő közvetítésnek vége - A Kijelentkezés minden helyben tárolt információt töröl. Bizonyosodj meg róla, hogy a privát kulcsod le van mentve, ezzel elkerülve a fiókod elvesztését. Folytatni akarod? - Követettek cimkéi - Csomópontok - Bejegyzés Felfedezés + Az élő közvetítés véget ért + A kijelentkezéssel törlődik az összes helyben tárolt adat. Győződjön meg arról, hogy a privát kulcsokról biztonsági mentést készített, hogy elkerülje fiókja elvesztését. Szeretné folytatni? + Követett címke + Átjátszók + Bejegyzések felfedezése Piac Élő Közösség - Társalgók - Elfogadott Bejegyzések - Ennek a csoportnak nincsen leírása vagy szabályzata. A módosítás miatt, a tulajdonossal egyeztess. - Ennek a csoportnak nincsen leírása. A módosítás miatt, a tulajdonossal egyeztess. + Csevegések + Elfogadott bejegyzések + Ennek a csoportnak nincs leírása vagy szabályai. Kérje meg a tulajdonosát, hogy adjon hozzá egyet + Ennek a csoportnak nincs leírása vagy szabályai. Kérje meg a tulajdonosát, hogy adjon hozzá egyet Érzékeny tartalom - Megjelenítés előtt egy érzékeny tartalom figyelmeztetés jelenik meg. - App Beállítások + Érzékeny tartalom-figyelmeztetés hozzáadása a tartalom megjelenítése előtt + Alkalmazás-beállítások Beállítások Mindig - Csak WIFI - Korlátlan WiFi + Csak Wi-Fi-n + Korlátlan Wi-Fi Soha Teljes Egyszerűsített - Teljesítmény-beállítások + Teljesítmény Rendszer Világos Sötét - Alkalmazás beállítások - Tárca Csatlakoztatás + Alkalmazás-beállítások + Kapcsolódás pénztárcához Nyelv Téma - Képek/GIF-ek automatikus betöltése - Videók automatikus betöltése - Az URL előnézetének automatikus megjelenítése + Képelőnézet + Videólejátszás + Webcím-előnézet Magával ragadó görgetés - Navigációs sáv görgetés közbeni elrejtése - Felület típusa - Válaszd ki a bejegyzés stílusát - Kép Betöltése - Spammerek - Lenémítva. Kattínts a feloldásért - Hang bekapcsolva. Kattints a némításért - Helyi és távoli adatok keresése - A Nostr cím ellenőrzésre került - A Nostr cím ellenőrzése sikeretelen - A Nostr cím ellenőrzése - Mind kijelölése/kijelölés visszavonása + 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 + Kép betöltése + Spamelők + Némítva. Koppintson a némitás megszüntetéséhez + Hang bekapcsolva. Koppintson a némításhoz + Helyi és távoli bejegyzések keresése + Nostr-cím hitelesítve lett + Nostr-cím hitelesítése sikeretelen + Nostr-cím ellenőrzése + Összes kijelölése / elvetése Alapértelmezett - Visszaállítás alaphelyzetbe - A folytatáshoz válassz egy csomópontot - Zap-ek továbbítása: - A funkciót támogató kliensek a Zap-eket az Ön tárcája helyett, az alábbi LN-címre vagy felhasználói profilra továbbítják - A Hely megjelenítése mint - A bejegyzéshez az Ön tartózkodási helyének Geohash-ét hozzáadja. A közönség tudni fogja, hogy az aktuális helytől 5 km-en (3 mérföldön) belül van - A kényes tartalom miatt, azon megjelenítése előtt figyelmeztetést ad. Ez ideális minden Felnőtt tartalomhoz vagy olyan tartalomhoz, amelyet egyesek sértőnek vagy zavarónak találhatnak + Visszaállítás alapértelmezettre + A folytatáshoz válasszon ki egy átjátszót + Zap-ek továbbítása neki: + A funkciót támogató kliensek a Zap-eket az alábbi LN-címre vagy felhasználói profilra továbbítják az Öné helyett + A helyszín megjelenítése mint + Hozzáadja a helyének geohash-sét a bejegyzéséhez. A nyilvánosság tudni fogja, hogy a jelenlegi helytől 5 km-en (3 mi) belül tertózkodik + Helyszín-alapú bejegyzés + Csak a helyszín követői láthatják. Az általános követők nem fogják látni. + Helyszín betöltése… + A helyszín-meghatározás nincs engedélyezve + Hozzáadja az érzékeny tartalomra vonatkozó figyelmeztetést a tartalom megjelenítése előtt. Ez ideális bármilyen NSFW tartalom vagy olyan tartalom esetén, amelyet egyesek sértőnek vagy zavarónak találhatnak Új funkció - Az Amethystnek ennek a módnak az aktiválásához NIP-17 üzenetet kell küldenie (GiftWrapped, Zárolt direkt és csoportos üzeneteket). A NIP-17 új, és a legtöbb kliens még nem implementálta. Győződj meg arról, hogy a fogadó fél kompatibilis klienst használ. + Ennek az üzemmódnak az aktiválásához az Amethystnek NIP-17 üzenetet kell küldenie (GiftWrapped, Sealed Direct és csoport-üzenetek). A NIP-17 új, és a legtöbb kliens még nem implementálta. Győződjön meg arról is, hogy a kedvezményezett kompatibilis klienst használ. Aktiválás - Publikus + Nyílvános Új nyilvános vagy privát csoport Privát - Címzett - Téma - A beszélgetés témája - "\@Felhasználó1, @Felhasználó2, @UFelhasználó3" + Neki: + Tárgy + A beszélgetés tárgya + "\@Felhasználó1, @Felhasználó2, @Felhasználó3" A csoport tagjai - Magyarázat a csoport tagjainak - Az új célok érdekében, a név megváltoztatása. + Kifejtés a csoport tagjai számára + Név megváltoztatása az új célok érdekében. Beszúrás vágólapról - Az applikáció felülete - Sötét, Világos vagy Rendszer által használt téma + Az alkalmazás felületéhez + Sötét, világos vagy a rendszer által használt téma Képek és GIF-ek automatikus betöltése - A videók és a GIF-ek automatikus lejátszása - URL előnézetek megjelenítése - Mikor kell a képeket betölteni + Videók és GIF-ek automatikus lejátszása + Webcím-előnézetek megjelenítése + Mikor töltse be a képeket Köteg másolása Másolás a vágólapra Npub másolása a vágólapra - Megosztás vagy Mentés - Az URL vágólapra másolása - A bejegyzésazonosító vágólapra másolása + Megosztás vagy mentés + Webcím másolása a vágólapra + Bejegyzés-azonosító másolása a vágólapra Média hozzáadása a galériához - Létrehozva + Létrehozva ekkor: Szabályok - Bejelentkezés Amber-el - Állapotod megjelenítése + Bejelentkezés Amberrel + Állapot frissítése Hiba a hibaüzenet elemzésekor - A szavazatokat a Zap-ek összegével súlyozzuk. Beállíthatsz egy minimális összeget, hogy a kéretlen leveleket elkerüld, és egy maximális összeget annak elkerülésére, hogy a szavazás feletti irányítást a nagy Zapperek vegyék át. Mindkét mezőben ugyanazt az összeget használd, hogy minden szavazat azonos értéket kapjon. Bármilyen összeg elfogadásához, hagyjd üresen. - Zap küldése nem sikerült - Üzenet a Felhasználónak - Ok - Nem sikerült elérni %1$s: %2$s - Nem sikerült összeállítani a NIP-11 URL-t a következőhöz: %1$s: %2$s - Nem sikerült elérni %1$s: %2$s - Nem sikerült a válasz elemzése a %1$s: %2$s - A csomópont elutasította a(z) %1$s kérést: %2$s - Nem sikerült elérni %1$s: %2$s - Nem sikerült az eredmény elemzése a %1$s: %2$s - %1$s hiba a következő kóddal %2$s + A szavazatok egy Zap összeggel vannak súlyozva. Beállíthat egy minimális összeget, hogy elkerülje a spamelőket, és egy maximális összeget, hogy elkerülje, hogy a nagy Zap-elők átvegyék a szavazást. Használja ugyanazt az összeget mindkét mezőben, hogy minden szavazatot ugyanannyira értékeljen. Hagyja üresen, hogy bármilyen összeget elfogadjon. + Nem sikerült Zap-et küldeni + Üzenet a felhasználónak + OK + Nem sikerült elérni a következőt: %1$s: %2$s + Nem sikerült összeállítani a NIP-11 webcímet a következőhöz: %1$s: %2$s + Nem sikerült elérni a következőt: %1$s: %2$s + Nem sikerült a válasz elemzése a következőtől: %1$s: %2$s + Az átjátszó elutasította a következő kérést: %1$s: %2$s + Nem sikerült elérni a következőt: %1$s: %2$s + Nem sikerült az eredmény elemzése a következőtől: %1$s: %2$s + A(z) %1$s sikertelen a következő kóddal %2$s Aktív: Főoldal - PÜk - Chat + Privát üzenetek + Csevegések Globális Keresés Zap-ek megosztása és továbbítása - A támogató kliensek a zapokat, az itt hozzáadott felhasználóknak helyetted felosztják és továbbítják + A funkciót támogató kliensek megosztják és továbbítják a Zap-eket az itt hozzáadott felhasználóknak az Ön felhasználói helyett Felhasználó keresése és hozzáadása Felhasználónév vagy megjelenítendő név - Hiányzó lightning beállítás - A %1$s felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással + Hiányzó lightning-beállítás + A(z) %1$s nevű felhasználó nem rendelkezik a satoshik fogadására beállított lightning-címmel Százalék 25 - Zap-ek megosztása - Zap-ek továbbítása - Lightning tárca nem található + A Zap-ek megosztása vele: + Zap-ek továbbítása ide: + Nem található lightning-pénztárca Fizetve - Tárca: %1$s - Hiba az aláíró alkalmazás megnyitásakor - Az aláíró alkalmazás nem található. Ellenőrizd, hogy az alkalmazás nincs-e eltávolítva - Aláírási kérés elutasítva - Győződj meg arról, hogy ezt a tranzakciót az aláíró alkalmazás engedélyezte-e - Nem található tárca a Lighning számla kifizetésére (Hiba: %1$s). Kérjük, a zap-ek használatához telepítsen egy Lightning pénztárcát - Nem található tárca a Lighning számla kifizetésére. Kérjük, a zap-ek használatához telepítsen egy Lightning pénztárcát - Elrejtett Szavak + Pénztárca: %1$s + Hiba az aláíró-alkalmazás megnyitásakor + Az aláíró alkalmazás nem található. Ellenőrizze, hogy az alkalmazás nincs-e eltávolítva + Aláíró-alkalmazás elutasítva + Győződjön meg arról, hogy ezt a tranzakciót az aláíró-alkalmazás hitelesítette-e + Nem található pénztárca a Lighning-számla kifizetéséhez (Hiba: %1$s). A Zap-ek használatához telepítsen egy Lightning-pénztárcát + Nem található pénztárca a Lighning-számla kifizetéséhez. A Zap-ek használatához telepítsen egy Lightning-pénztárcát + Rejtett szavak Új szó vagy mondat elrejtése Profilkép Profilképek megjelenítése - Válassz egy lehetőséget - A számla kifizetése sikertelen - A kivét sikertelen - Nem sikerült a Wallet Connect-et beállítani - Hiba a NIP-47 kapcsolati karakterlánc elemzése során. Ellenőrizze, hogy a Tárca-szolgáltatónál a következő helyes-e: %1$s. Hiba: %2$s - Hiba a NIP-47 kapcsolati karakterlánc elemzése során. Ellenőrizze, hogy a Tárca-szolgáltatónál a következő helyes-e: %1$s. - Nem sikerült a Cashu-t beváltani - A verde a következő hibaüzenetet küldte:%1$s + Válasszon egy lehetőséget + Sikertelen számla-kifizetés + Sikertelen pénzkivétel a pénztárcából + Nem sikerült beállítani a Wallet Connectet + Hiba a NIP-47 kapcsolati karakterlánc elemzésekor. Ellenőrizze, hogy a pénztárca-szolgáltatónál a következő helyes-e: %1$s. Hiba: %2$s + Hiba a NIP-47 kapcsolati karakterlánc elemzésekor. Ellenőrizze, hogy a pénztárca-szolgáltatónál a következő helyes-e: %1$s. + Nem sikerült a Cashut beváltani + A pénzverde a következő hibaüzenetet küldte:%1$s A Cashu tokeneket már elköltötték. - Cashu Megkapva - %1$s sats érkezett a tárcádba. (Díjak: %2$s sats) + Cashu megkapva + %1$s satoshi érkezett a pénztárcájába. (Költség: %2$s satoshi) A rendszeren nem található kompatibilis Cashu pénztárca - Nem sikerült a számlát a címzett szervereiről lekérni - A Wallet Connect szolgáltatója a következő hibát jelezte: %1$s - Nem sikerült a Tor hálózathoz csatlakozni - Nem elérhető a csomópont dokumentumának letöltése - Nem sikerült az LNUrl-t a(z) \"%1$s\" Lightning-címről összeállítani. Ellenőrizd a felhasználó beállítását - A fogadó lightning szolgáltatása itt: %1$s nem érhető el. A \"%2$s\" lightning címből lett kiszámítva. Hiba: %3$s. Ellenőrizd, hogy a szerver működik-e, és hogy a lightning cím helyes-e - Nem sikerült megoldani: %1$s. Ellenőrizd, hogy csatlakozol-e, működik-e a szerver, és hogy a %2$s lightning cím helyes-e - Nem sikerült megoldani: %1$s. Ellenőrizd, hogy csatlakozol-e, működik-e a szerver, és hogy a %2$s lightning cím helyes-e.\n\nKivétel: %3$s - Nem sikerült a számlát a következőtől: %1$s lekérni - Hiba a Lightning-címből származó JSON elemzésekor. Ellenőrizd a felhasználó Lightning beállítását - Hiba történt a %1$s JSON elemzése során. Ellenőrizd a felhasználó Lightning beállítását - A visszahívási URL a felhasználó Lightning címszerver konfigurációjában nem található - A visszahívási URL a %1$s válaszából nem található - Hiba a Lightning cím számlalekérése során származó JSON elemzésekor. Ellenőrizd a felhasználó Lightning beállítását - Hiba történt a %1$s számlalekérésnek a JSON elemzése során. Ellenőrizd a felhasználó Lightning beállítását - Helytelen számlaösszeg (%1$s sats) a következőtől: %2$s. %3$s kellett volna - Nem sikerült a zap elküldése előtt lightning számlát készíteni. A fogadó fél lightning tárcája a következő hibát küldte: %1$s - Nem sikerült Lightning számlát létrehozni. Üzenet a következőtől %1$s: %2$s - Nem sikerült a zap elküldése előtt lightning számlát készíteni. A kapott JSON-ban a pr elem nem található. - Nem lehet villámszámlát létrehozni a következőből: %1$s: A kapott JSON-ban nem található fizetési kérés elem. + Nem sikerült a számlát a kedvezményezett kiszolgálóiról lekérni + A Wallet Connect szolgáltatója a következő hibával tért vissza: %1$s + Nem sikerült a Tor hálózathoz kapcsolódni + Nem érhető el az átjátszó-dokumentum + Nem sikerült az LN-webcímet a(z) „%1$s” Lightning-címről összeállítani. Ellenőrizze a felhasználó beállításait + A kedvezményezett lightning-szolgáltatása ennél: %1$s nem érhető el. A(z) „%2$s” lightning-cím lett kiszámítva. Hiba: %3$s. Ellenőrizze, hogy a kiszolgáló működik-e, és hogy a lightning-cím helyes-e + Nem sikerült megoldani a következőt: %1$s. Ellenőrizze, hogy kapcsolódik-e, vagy működik-e a kiszolgáló, és hogy a(z) %2$s lightning-cím helyes-e + Nem sikerült megoldani a következőt: %1$s. Ellenőrizze, hogy kapcsolódik-e, vagy működik-e a kiszolgáló, és hogy a(z) %2$s lightning-cím helyes-e.\n\nKivéve: %3$s + Nem sikerült a számlát a következőtől lekérni: %1$s + Hiba a Lightning-címből származó JSON elemzésekor. Ellenőrizze a felhasználó Lightning-beállítását + Hiba történt a(z) %1$s JSON elemzésekor. Ellenőrizze a felhasználó Lightning-beállítását + A visszahívási webcím nem található a felhasználó lightning-cím-kiszolgálójának konfigurációjában + Nem található a visszahívási webcím a(z) %1$s válaszából + Hiba történt a JSON elemzésekor a Lightning-cím számlahívásából. Ellenőrizze a felhasználó lightning-beállításait + Hiba történt a JSON elemzésekor a(z) %1$s számlahívásából. Ellenőrizze a felhasználó lightning-beállításait + Helytelen (%1$s satoshi) számlaösszeg a következőtől: %2$s. A következőnek kellett volna lennie: %3$s + Nem sikerült a Zap-összeg elküldése előtt lightning-számlát készíteni. A címzett lightning-pénztárcája a következő hibát küldte: %1$s + Nem lehetett lightning-számlát létrehozni. Üzenet a következőtől: %1$s: %2$s + Nem sikerült Lightning-számlát létrehozni a Zap elküldése előtt. A „PR” elem nem található a kapott JSON-ban. + Nem sikerült Lightning-számlát létrehozni a következőből: %1$s: A „PR” elem nem található a kapott JSON-ban. Csak olvasható felhasználó Nincs reakcióbeállítás - Válassz ki egy UnifiedPush alkalmazást - Push értesítés - A telepített UnifiedPush alkalmazásokból + UnifiedPush-alkalmazás kiválasztása + Push-értesítés + A telepített UnifiedPush-alkalmazásokból Nincs - Push értesítések letiltása - A(z) %1$s alkalmazást használja - Push értesítések beállítása - A push értesítések fogadásához olyan alkalmazást telepíts, amely támogatja a [Unified Push](https://unifiedpush.org/), például az [Ntfy](https://ntfy.sh/) alkalmazást. - A telepítés után a Beállításokban válaszd ki a használni kívánt alkalmazást. + Push-értesítések letiltása + A következő alkalmazást használja: %1$s + Push-értesítések beállítása + A push-értesítések fogadásához olyan alkalmazást telepítsen, amely támogatja a [Unified Push](https://unifiedpush.org/), például az [Ntfy](https://ntfy.sh/) alkalmazást. + A telepítés után a beállításokban válassza ki a használni kívánt alkalmazást. - Üzenet %1$s-tól - Gondolatmenet - Küldj az eladónak üzenetet - Szia %1$s, ez még aktuális? - Szia, ez még aktuális? - Elem eladása + Üzenet tőle: %1$s + Bejegyzés + Üzenet küldése az eladónak + Tisztelt %1$s! Ez még aktuális? + Tisztelt hölgyem / uram! Ez még aktuális? + Egy tétel eladása Cím iPhone 13 Állapot @@ -678,51 +703,52 @@ Helyszín Város, állam, ország Új - Teljesen új készülék, eredeti dobozában + Teljesen új készülék az eredeti dobozában Újszerű - Használt, de a használat nyomai nincsenek rajta + Használt, de nincs nyoma a használatnak - Felületes használati nyomok vannak rajta - Közepes + Van néhány felületes használati sérülés + Elfogadható Még mindig elfogadható és működőképes állapotban van Ruházat Tartozékok - Elektronikai cikkek + Elektronika Bútor Gyűjtemények Könyvek Háziállatok Sport - Fitnesz + Egészség / Fitnesz Művészet - Iparművészet - Otthon + Kézműves + Háztartás Irodaszer Étel Vegyes Egyéb - Nem sikerült a médiát feltölteni - Nem sikerült a tömörített fájlt megnyitni + Nem sikerült feltölteni a médiát + Nem sikerült megnyitni a tömörített fájlt Hiba a média tömörítésekor: %1$s Feltöltési hiba: %1$s - A szerver a feltöltés után nem adott URL-t - Nem sikerült a szerverről a feltöltött médiát letölteni - Nem sikerült előkészíteni a helyi fájlt a feltöltésre: %1$s + A kiszolgáló a feltöltés után nem szolgáltatott webcímet + Nem sikerült letölteni a kiszolgálóról az oda feltöltött médiát + Nem lehetett ellenőrizni a letöltött fájlt a feltöltés után: %1$s + Nem sikerült előkészíteni feltöltésre a helyi fájlt: %1$s Nem sikerült feltölteni: %1$s Nem sikerült törölni: %1$s Média túl nagy a NIP-95 számára - Indexképet nem sikerült betölteni - Nem sikerült előkészíteni a fejléc információkat: %1$s - Tömörítés megszakítva + Nem sikerült betölteni a bélyegképet + Nem sikerült előkészíteni a fejlécadatokat: %1$s + Tömörítés visszavonva A tömörítés nem tudott visszaadni egy fájlt - Média Minősége - Válasszd az Alacsony minőség lehetőséget, ha a médiát gyengébb minőségben kisebb fájlba szeretnéd tömöríteni, vagy válasszd a Magas minőség lehetőséget, ha jobb minőségben nagyobb fájlba szeretnéd tömöríteni. + Média minősége + Válassza az alacsony minőséget a média kisebb, de kevésbé jó minőségű fájlba tömörítéséhez, a magas minőséget a nagyobb, de jobb minőségű fájlba tömörítéshez, vagy a tömörítetlen fájlt a tömörítés nélküli feltöltéshez. Alacsony Közepes Magas Tömörítetlen Piszkozat szerkesztése - Bejelentkezés QR kóddal + Bejelentkezés QR-kóddal Útvonal Főoldal Keresés @@ -730,149 +756,155 @@ Üzenetek Értesítések Globális - Rövidfilmek + Rövidek Biztonsági szűrők Új bejegyzés - Új rövidfilmek: képek vagy videók + Új rövidek: képek vagy videók Új közösségi bejegyzés - Nyissa meg a bejegyzésre adott összes reakciót - Zárja be a bejegyzésre adott összes reakciót + A bejegyzésre adott összes reakció kibontása + A bejegyzésre adott összes reakció összecsukása Válasz - Megosztás vagy Idézés - Like + Megtolás vagy Idézés + Tetszik Zap - Változtasd meg a gyors reakciókat + Gyors reakciók megváltoztatása %1$s profilképe - %1$s csomópont - Csomópont lista kibontása - Bejegyzés opciók - Csomópont lista választó + %1$s átjátszó + Átjátszólista kibontása + Bejegyzés-beállítások + Átjátszólista-választó Szavazás Szavazás kikapcsolása - Bitcoin számla - Bitcoin számla visszavonása + Bitcoin-számla + Bitcoin-számla visszavonása Egy tétel eladásának visszavonása - ZapGyűjtés - ZapGyűjtés visszavonása - Pozíció - Pozíció törlése - Zap megosztások - Zap megosztások visszavonása - Tartalomra vonatkozó figyelmeztetés - Tartalomra vonatkozó figyelmeztetés visszavonása - Az npub megjelenítése QR-kódként + Zap-gyűjtés + Zap-gyűjtés visszavonása + Helyszín + Helyszín eltávolítása + Zap-megosztások + Zap-megosztások visszavonása + Tartalmi figyelmeztetés hozzáadása + Tartalmi figyelmeztetés eltávolítása + Az npub-kulcs megjelenítése QR-kódként Érvénytelen cím Az Amethyst kapott egy URI-t a megnyitáshoz, de az érvénytelen volt: %1$s - PÜ Bejövő Csomópontok - Csomópontok: %1$s - Általános csomópontok használata - Állítsd be a Privát postafiókod közvetítőit - Ez a beállítás mindenkit tájékoztat, hogy melyik közvetítőt használod, amikor üzeneteket küldenek neked. Nélkülük néhány üzenetről lemaradhatsz. - Jó lehetőségek a következők:\n - inbox.nostr.wine (fizetős)\n - you.nostr1.com (személyes csomópontok - fizetős) - Jó lehetőségek:\n - auth.nostr1.com (ingyenes)\n - inbox.nostr.wine (fizetős)\n - relay.0xchat.com (ingyenes) - Adj hozzá 1–3 csomópontot, hogy privát postafiókodként szolgáljon. A PÜ Bejővő csomópontoknak el kell fogadniuk bármely üzenetet bárkitől, de azok letöltését csak Te általad teszik lehetővé. - Állítsd be most - Kereső Csomópontok - Állítsd be a Kereséső csomópontjaidat - A kifejezetten kereséshez és felhasználói címkézéshez tervezett csomópontok listájának létrehozása javítja a találati eredményeket. - Adj hozzá 1–3 csomópontot tartalom kereséshez vagy a felhasználók címkézéséhez. Győződj meg arról, hogy a kiválasztott csomópontok alkalmazzák a NIP-50-et - Jó lehetőségek a következők:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com - Csomópont Beállítások - Publikus Otthoni Csomópontok - Ez a csomópont típus az összes tartalmat tárolja. Az Amethyst ide küldi a bejegyzéseidet, mások pedig ezeket a csomópontokat fogják használni, hogy a tartalmadat megtalálják. Adj hozzá 1-3 csomópontot. Lehetnek saját, fizetős vagy nyilvános csomópontok. - Publikus Bejövő Csomópontok - Ez a csomópont típus fogadja a bejegyzéseidre adott összes választ, megjegyzést, tetszésnyilvánítást és visszajelzést. Adj hozzá 1–3 csomópontot, és győződj meg arról, hogy ezek bárkitől fogadnak bejegyzéseket. - PÜ Bejövő Csomópontok - Adj hozzá 1–3 csomópontot, hogy privát postafiókodként szolgáljon. Mások ezeket a csomópontokat használják, hogy neked PÜ-eket küldjenek. A PÜ Bejövő csomópontoknak bárkitől kell fogadniuk bármely üzenetet, de azok letöltését csak a Te részedre teszik lehetővé. Jó lehetőségek a következők:\n - inbox.nostr.wine (fizetős)\n - you.nostr1.com (személyes csomópontok - fizetős) - Privát Csomópontok - Adj hozzá 1–3 csomópontot, hogy olyan eseményeket tároljon, amelyeket senki más nem láthat, például a piszkozataidat és/vagy az alkalmazásbeállításaidat. Ideális esetben ezek a csomópontok vagy helyiek, vagy az egyes felhasználók tartalmának letöltése előtt hitelesítést igényelnek. - Általános Csomópontok - Az Amethyst ezeket a csomópontokat a bejegyzések letöltésére használja. - Ajánlott csomópontok - Add hozzá a következő csomópontokat az Általános Csomópontok listájához, hogy a felsorolt ​​felhasználóktól üzeneteket kapj. - Kereső Csomópontok - A tartalom vagy a felhasználók kereséséhez használt csomópontok listája. A címkézés és a keresés nem működik, ha itt nincsenek megadott lehetőségek. Győződj meg arról, hogy alkalmazzák a NIP-50-et. - Helyi Csomópontok - Ezen eszközön futó csomópontok listája. + Bejövő átjátszók a privát üzenetekhez + Átjátszók: %1$s + Általános átjátszók használata + Bejövő átjátszók beállítása a privát üzenetekhez + Ez a beállítás mindenkit tájékoztat arról, hogy Ön melyik átjátszót használja, amikor üzeneteket küldenek Önnek. Ezek nélkül néhány üzenetről lemaradhat. + Jó választási lehetőségek:\n - inbox.nostr.wine (fizetős)\n - auth.nostr1.com (ingyenes)\n - you.nostr1.com (személyes átjátszók - fizetős) + Jó választási lehetőségek:\n - auth.nostr1.com (ingyenes)\n - inbox.nostr.wine (fizetős)\n - relay.0xchat.com (ingyenes) + Adjon hozzá 1–3 átjátszót, hogy privát postafiókként szolgáljanak. A bejövő privát üzenetek átjátszóinak el kell fogadniuk bármely üzenetet bárkitől, de azok letöltését csak Ön engedélyezheti. + Beállítás most + Keresési átjátszók + Keresési átjátszók beállítása + A kifejezetten a kereséshez és a felhasználói címkézéshez tervezett átjátszólista létrehozása javítani fogja ezeket az eredményeket. + Adjon hozzá 1–3 átjátszót a tartalom kereséshez vagy a felhasználók címkézéséhez. Győződjön meg arról, hogy a kiválasztott átjátszók alkalmazzák a NIP-50-et + Jó választási lehetőségek:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Átjátszó-beállítások + Nyilvános kimenő és saját átjátszók + Ez az átjátszótípus tárolja az összes tartalmat. Az Amethyst ide küldi az Ön bejegyzéseit, és mások ezeket az átjátszókat fogják használni, hogy megtalálják az Ön tartalmát. Adjon hozzá 1–3 átjátszót. Ezek lehetnek személyes-, fizetett- vagy nyilvános átjátszók. + Nyilvános bejövő átjátszók + Ez az átjátszótípus fogadja az összes választ, hozzászólást, kedvelést és Zap-et az Ön bejegyzéseire. Ezek lehetnek fizetős vagy ingyenes átjátszók. Az átjátszó üzemeltetője által beállított korlátok korlátozhatják a jó és a rossz értesítések számát. Ha például a hozzászólásokban kéretlen üzenet-támadások érik, a fizetős átjátszók kiszűrhetik a kéretlen tartalmakat. Vegyen fel 1–3 átjátszót. + Bejövő közvetlen üzenet-átjátszók + Adjon hozzá 1–3 átjátszót, hogy privát postafiókként szolgáljon. Mások ezeket az átjátszókat használják, hogy Önnek privát üzeneteket küldjenek. A bejövő privát üzenetek átjátszóinak bárkitől el kell fogadniuk minden üzenetet, de azok letöltését csak Ön engedélyezheti. Jó választási lehetőségek:\n - inbox.nostr.wine (fizetős)\n - auth.nostr1.com (ingyenes)\n - you.nostr1.com (személyes átjátszók - fizetős) + Privát saját átjátszók + Adjon hozzá 1–3 átjátszót, hogy olyan eseményeket tároljanak, amelyeket senki más nem láthat, például a piszkozatait és/vagy az alkalmazásbeállításait. Ideális esetben ezek az átjátszók vagy helyi szintűek, vagy hitelesítést igényelnek az egyes felhasználói tartalmak letöltése előtt. + Általános átjátszók + Az Amethyst ezeket az átjátszókat a bejegyzések letöltésére használja. + Ajánlott átjátszók + Adja hozzá a következő átjátszókat az általános átjátszók listájához, hogy megkapja a felsorolt felhasználók hozzászólásait. + Keresési átjátszók + A tartalom vagy felhasználók keresésekor használandó átjátszók listája. A címkézés és a keresés nem fog működni, ha nem áll rendelkezésre semmilyen beállítás. Győződjön meg arról, hogy alkalmazzák a NIP-50-et. + Helyi átjátszók + A készüléken futó átjátszók listája. Zap a fejlesztőknek! - Az adományod hozzájárul ahhoz, hogy változást érjünk el. Minden sat számít! - Adományozz Most - hozta neked: - Ezt a verziót hozta neked: + Az Ön adománya segít nekünk abban, hogy változtassunk a dolgokon. Minden satoshi számít! + Adományozás most + a következők hozták létre Önnek: + Ezt a verziót a következők hozták létre Önnek: %1$s verzió Köszönöm! - Max Limit + Maximális korlát Korlátozott írások - Fork-olva innen - FORK - Git-tár: %1$s - Web: - Klónoz: + Elágaztatva innen: + ELÁGAZTATÁS + Git tároló: %1$s + Weboldal: + Klónozás: OTS: %1$s - Időbélyeg igazolás - Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban a Bitcoin blokkláncába bélyegezték. + Időbélyeg-igazolás + Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban bélyegezték a Bitcoin blokkláncába. Bejegyzés szerkesztése Javaslat egy bejegyzés javítására Változások összefoglalása Gyors javítások… - Fogadd el a Javaslatot + Javaslat elfogadása Letöltés Feliratozás bekapcsolva Feliratozás kikapcsolva - Lezárt üzenetek kikapcsolva. Kattints a lezárt üzenetek bekapcsolásához - Lezárt üzenetek bekapcsolva. Kattints a lezárt üzenetek kikapcsolásához + Lezárt üzenetek kikapcsolva. Koppintson a lezárt üzenetek bekapcsolásához + Lezárt üzenetek bekapcsolva. Koppintson a lezárt üzenetek kikapcsolásához Küldés Felhasználónév lejátszása hangként + Kapcsolatrögzítő (Pushpin) QR-kód beolvasása - Keresd fel a harmadik féltől származó pénztárcaszolgáltatót, az Alby-t - A bejegyzésvázlatra nem lehet válaszolni - A bejegyzésvázlatot nem lehet idézni - A bejegyzésvázlatra nem lehet reagálni - A bejegyzésvázlatra nem lehet zap-pelni - Bejegyzéspiszkozat - Tőle üzenet - Alkalmazást keresek - Feladat kérve, válaszra várunk - Feladatot kér a DVM-től - Fizetési kérelem elküldve, megerősítésére vár a tárcádtól - Várakozás a DVM-re a fizetés megerősítésére vagy az eredmények elküldésére - Hibás kérés – A szerver nem tudja vagy nem akarja a kérést feldolgozni. - Jogosulatlan – A felhasználó nem rendelkezik érvényes hitelesítési adatokkal - Fizetés szükséges – A szerver fizetséget kér a kérés teljesítéséhez - Tiltott – A felhasználónak nincs hozzáférési joga a kérelem benyújtásához - Nem található – A szerver a kért címet nem találja - Módszer Nem Engedélyezett – A kiszolgáló támogatja a kérési módszert, de a célerőforrás nem - Nem elfogadható – A szerver nem talál olyan tartalmat, amely a kérést kielégítené. - Proxy hitelesítés szükséges – A felhasználó nem rendelkezik érvényes hitelesítési adatokkal - Kérelem időtúllépése – A szerver időtúllépés miatt valaki másra vár - Ütközés – A szerver nem tudja teljesíteni a kérést, mert az erőforrással konfliktus van - Eltűnt – A kért tartalmat a szerverről véglegesen törölték, és nem lesz visszaállítva - Kötelező hossz – A szerver elutasítja a kérést, mert nincs kellően definiálva - Előfeltétel nem sikerült – A fejlécben lévő kérés előfeltételeit, a szerver nem teljesíti - A kérés túl nagy – A kérés nagyobb, mint a szerver által meghatározott korlátok, és a szerver nem hajlandó feldolgozni - Túl hosszú URI – Az ügyfél által kért URL túl hosszú ahhoz, hogy a szerver feldolgozza. - Nem támogatott médiatípus – A kérés olyan médiaformátumot használ, amelyet a szerver nem támogat - Tartomány nem teljesíthető – A szerver nem tudja teljesíteni a kérés Tartomány fejlécében jelzett értéket. - Az elvárás nem sikerült – A szerver nem tudja teljesíteni a Várható kérés fejlécében jelzett követelményeket - Frissítés szükséges – A szerver megtagadja a kérés feldolgozását az aktuális protokoll használatával, hacsak az ügyfél nem frissít egy másik protokollra. - Belső szerverhiba – A szerver váratlan hibát észlelt, és nem tudja a kérést teljesíteni - Nincs végrehajtva – A szerver nem tudja a kérést teljesíteni, vagy nem ismeri fel a kérés módszerét - Rossz átjáró – A szerver átjáróként működik, és érvénytelen választ kapott a gazdagéptől - A szolgáltatás nem elérhető – Ez gyakran akkor fordul elő, ha a kiszolgáló túlterhelt vagy karbantartás miatt leáll - Átjáró Időtúllépés – A szerver átjáróként vagy proxyként működött, és a válaszra várva időtúllépés történt - A HTTP-verzió nem támogatott – A szerver a kérésben szereplő HTTP-verziót nem támogatja - Váltózó porbléma – A kiszolgálón belső konfigurációs hiba lépett fel - Elégtelen tárhely – A szervernek nincs elég tárhelye a kérés sikeres feldolgozásához - Hurok észlelve – A szerver a kérés feldolgozása közben végtelen hurkot észlel - Hálózati hitelesítés szükséges – A klienst a hálózat eléréséhez hitelesíteni kell - Adj hozzá egy NIP-96 szervert - Törlés mind - Biztosan törölni szeretnéd az összes jegyzetet? + Harmadik féltől származó pénztárca-szolgáltató felkeresése: Alby + A piszkozatokra nem lehet válaszolni + A piszkozatokat nem lehet idézni + A piszkozatokra nem lehet reagálni + A piszkozatokra nem lehet Zap-et küldeni + Piszkozat + Üzenet tőle + Alkalmazás keresése + Feladat kérve, várakozás a válaszra + Feladat kérése a DVM-től + Fizetési kérelem elküldve, megerősítésre vár az Ön pénztárcájától + Várakozás arra, hogy a DVM megerősítse a fizetést vagy elküldje az eredményeket + 400 – hibás kérés: A kérés nem végrehajtható, mert rossz szintaxisú, túl hosszú stb. + 401 – nem azonosított: A kliensnek nincs jogosultsága megtekinteni az oldalt. + 402 – fizetés szükséges: Fizetnie kell a kiszolgálónak a kérés teljesítéséhez + 403 – nincs jogosultság: A kliensnek nincs jogosultsága megtekinteni az oldalt. + 404 – nem található: A kért erőforrás nem létezik. + 405 – a metódus nem engedélyezett: A használt HTTP-metódus nem engedélyezett, pl egy PUT kérés egy csak olvasható erőforráson. + 406 – nem elfogadható: Jelzi, hogy a szerver nem tud választ előállítani az Accept, az Accept-Charset, az Accept-Encoding vagy az Accept-Language fejlécben megadott tulajdonságok miatt. + 407 – proxyazonosítás szükséges: A kliensnek azonosítania kell magát a proxykiszolgálón. + 408 – időtúllépés: A kiszolgáló túl sokáig várt a kérésre, így a kapcsolat lezárult. + 409 – konfliktus: Jelzi, hogy az erőforrás nem elérhető, mert ütközne egy másik folyamattal, pl. egy dokumentum egyidejű szerkesztése. + 410 – véglegesen eltűnt: A kért erőforrás nem létezik, és ez így is fog maradni a jövőben is. + 411 – probléma a mérettel: A kérés mérete nem megfelelő. + 412 – a kliens nem megfelelő: A kliens nem felel meg az egyik feltételnek. + 413 – hosszú kérés: A kérés nagyobb a kiszolgáló által maximálisan feldolgozhatónál. + 414 – hosszú URI: Az URI hosszúsága nagyobb a megengedettnél. + 415 – nem támogatott médiatípus: A kérés olyan médiaformátumot használ, amelyet a kiszolgáló nem támogat + 416 – a fájlpozíció nem megfelelő: A kliens egy darabját kérte a fájlnak, de a kiszolgáló ezt nem tudja kézbesíteni. Például a kliens egy olyan részét kérte a fájlnak, ami a fájl vége után található. + 417 – a kiszolgáló nem megfelelő: A kiszolgáló nem felel meg azoknak a követelményeknek, amit a kliens az Expect fejlécben meghatározott. + 426 – frissítés szükséges: A kiszolgáló felszólítja a klienst, hogy váltson protokollt. (Pl. hogy biztonságos legyen a kapcsolat.) + 500 – belső kiszolgálóhiba: Egy általános hibaüzenet kiszolgálóhibák jelzésére. + 501 – nincs megvalósítva: A kiszolgáló nem ismerte fel a HTTP-metódust, vagy nem képes teljesíteni a kérést, de a jövőben ez a helyzet meg fog változni. + 502 – rossz átjáró: Jelzi, hogy a kiszolgáló egy hibás HTTP-választ kapott egy másik kiszolgálótól. + 503 – a szolgáltatás nem elérhető: A kiszolgáló nem képes kezelni a kérést. Általában ez a helyzet csak ideiglenes. + 504 – átjáró időtúllépés: Proxyk használják. Jelzi, hogy a kiszolgáló nem kapott választ időben az eredeti kiszolgálótól. + 505 – nem támogatott HTTP-verzió: A kiszolgáló nem támogatja a kliens által használt HTTP-verziót. + 506 – körkörös hivatkozás: A kiszolgálót rosszul konfigurálták. A kért erőforrás körkörös hivatkozást eredményez. + 507 – kevés tárhely (WebDAV): A kiszolgáló nem képes a kérést tárolni, mert kevés a tárhely. + 508 – végtelen ismétlés (WebDAV): A kiszolgáló végtelen ciklusban ismétlődő kéréseket érzékelt a feldolgozáskor. + 511 – hálózati azonosítás: A hálózat használata előtt azonosítani kell magunkat, pl. egy Wi-Fi-hálózat használata előtt el kell fogadni a használati feltételeket. + NIP-96-kiszolgálók + Annyi kiszolgálót adhat hozzá, amennyit csak akar. Később a kép feltöltésekor kiválaszthatja, melyiket szeretné használni + Blossom-kiszolgálók + Annyi kiszolgálót adhat hozzá, amennyit csak akar. Később a kép feltöltésekor kiválaszthatja, melyiket szeretné használni + Egy NIP-96-kiszolgáló hozzáadása + Egy Blossom-kiszolgáló hozzáadása + Összes törlése + Biztosan törölni szeretné az összes piszkozatot? Köteg: - Torrent Fájl + Torrentfájl Letöltés Nem sikerült megnyitni a fájlt - A fájl megnyitásához és letöltéséhez nincsenek torrentalkalmazások telepítve. - Válassz egy listát a hírcsatorna szűréséhez - Eszköz zárolása esetén jelentkezzen ki + A fájl megnyitásához és letöltéséhez nincsenek torrent-alkalmazások telepítve. + Lista kiválasztása a hírfolyam szűréséhez + Kijelentkeztetés az eszköz zárolása esetén diff --git a/amethyst/src/main/res/values-ku-rTR/strings.xml b/amethyst/src/main/res/values-ku-rTR/strings.xml index 66a570486..3ea04e700 100644 --- a/amethyst/src/main/res/values-ku-rTR/strings.xml +++ b/amethyst/src/main/res/values-ku-rTR/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-nl-rBE/strings.xml b/amethyst/src/main/res/values-nl-rBE/strings.xml index 66a570486..3ea04e700 100644 --- a/amethyst/src/main/res/values-nl-rBE/strings.xml +++ b/amethyst/src/main/res/values-nl-rBE/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-nl/strings.xml b/amethyst/src/main/res/values-nl/strings.xml index 7a2ef8eef..6a97ecea2 100644 --- a/amethyst/src/main/res/values-nl/strings.xml +++ b/amethyst/src/main/res/values-nl/strings.xml @@ -116,6 +116,7 @@ Profielfoto URL Banner URL Website URL + Voornaamwoorden LN Adress LN URL (verouderd) Opslaan in galerij @@ -147,6 +148,8 @@ Lightning Adress Kopieert het NSEC ID (uw wachtwoord) naar klembord voor back-up. Geheime sleutel kopiëren naar het klembord + Toon QR-code van privésleutel + Toon versleutelde QR-code van privésleutel Kopieert de publieke sleutel naar klembord om te delen Kopieer publieke sleutel (NPUB) naar klembord Stuur een privébericht @@ -183,6 +186,7 @@ Account wordt geladen "Foutmelding bij het laden reacties: " Opnieuw proberen + Nog geen meldingen. Feed is leeg. verversen gecreëerd @@ -202,6 +206,8 @@ vertaald van naar Eerst laten zien in %1$s + Openbare chat over %1$s + Openbare community over %1$s Altijd vertalen naar %1$s Nooit vertalen vanuit %1$s Nostr adres @@ -332,10 +338,13 @@ Wat betekent dit? De afbeelding is onveranderd sinds de post De afbeelding is veranderd. De auteur heeft de wijziging misschien niet gezien + Voeg media toe Afbeelding toevoegen Video toevoegen Document toevoegen Voeg toe aan bericht + Onderschrift toevoegen + Mijn goede vriend Beschrijving van de inhoud Een blauwe boot in een wit zandstrand bij zonsondergang Zap type @@ -349,15 +358,25 @@ Non-Zap Geen spoor binnen Nostr, alleen in Lightning File server + Kies een server om dit bestand naar te uploaden LnAddress of @User Mediaservers Stel uw gewenste media upload servers in. - Je hebt geen aangepaste mediaservers ingesteld. Je kunt de lijst van Amethyst gebruiken of er hieronder een toevoegen ↓ + Je hebt geen NIP-96 servers ingesteld. Je kunt de lijst van Amethyst gebruiken of er hieronder een toevoegen ↓ + Je hebt geen Blossom servers ingesteld. Je kunt de lijst van Amethyst gebruiken of er hieronder een toevoegen ↓ Ingebouwde mediaservers Amethyst\'s standaardlijst. U kunt ze individueel toevoegen of de lijst toevoegen. Gebruik standaard lijst Mediaserver toevoegen Mediaserver verwijderen + Niet gestart + Comprimeren + Uploaden + Verwerken + Downloaden + Hashen + Klaar + Fout Uw relays (NIP-95) Bestanden worden geüpload naar en gehost door relays. Ze zijn vrij van een vaste url (afhankelijkheid van derden). Zorg ervoor dat u een NIP-95 relay in uw lijst met relays hebt. Privacyopties @@ -371,6 +390,7 @@ Nee Volgerslijst Iedereen + In de buurt Globaal Mute-lijst Standaard poort is 9050 @@ -540,11 +560,16 @@ Nostr-adres checken Alles selecteren/deselecteren Standaard + Standaardinstellingen herstellen Selecteer een relay om verder te gaan Stuur Zaps door naar: Ondersteunende clients sturen zaps door naar het onderstaande LN-adres of gebruikersprofiel in plaats van naar het jouwe Locatie weergeven als Voegt een Geohash van je locatie toe aan het bericht. Het publiek weet dat je binnen 5 km (3mi) van de huidige locatie bent. + Locatie-exclusief bericht + Alleen volgers van de locatie zien het. Je volgers zien het niet. + Locatie laden + Geen locatiemachtigingen Voegt een waarschuwing voor gevoelige inhoud toe voordat je inhoud wordt weergegeven. Dit is ideaal voor NSFW-inhoud of inhoud die sommige mensen beledigend of verontrustend vinden. Nieuwe functie Voor het activeren van deze modus is Amethyst nodig om een NIP-17 bericht te versturen. NIP-17 is nieuw en de meeste clients hebben deze nog niet geïmplementeerd. Zorg ervoor dat de ontvanger een compatibele client gebruikt. @@ -707,6 +732,7 @@ Upload fout: %1$s Server heeft geen URL opgegeven na uploaden Kan de geüploade media niet downloaden van de server + Kan het gedownloade bestand niet controleren na upload: %1$s Kon lokaal bestand niet voorbereiden voor upload: %1$s Uploaden van media mislukt: %1$s Verwijderen van media mislukt: %1$s @@ -769,6 +795,7 @@ Stel uw Privé Postvak relays in Deze instelling geeft iedereen informatie over de relays die gebruikt worden bij het verzenden van berichten. Zonder hen mis je mogelijk een bericht. Goede opties zijn:\n - inbox.nostr.wine (betaald)\n - you.nostr1.com (persoonlijke relays - betaald) + Goede opties zijn:\n - auth.nostr1.com (gratis)\n - inbox.nostr.wine (betaald)\n - relay.0xchat.com (gratis) Voeg tussen de 1 en 3 relays toe om als privéinbox te dienen. DM inbox relays moeten een bericht van iedereen accepteren, maar alleen om berichten te downloaden. Nu instellen Zoek relays @@ -822,6 +849,7 @@ Verzegeld bericht aan. Klik om Verzegeld bericht uit te zetten Verzenden Speel gebruikersnaam af als audio + Pushpin Scan de QR-code Navigeer naar de externe wallet provider Alby Het is niet mogelijk om een concept te beantwoorden @@ -864,7 +892,12 @@ Onvoldoende opslagruimte - De server heeft niet genoeg opslagruimte om het verzoek succesvol te verwerken Loop gedetecteerd - De server detecteert een oneindige loop tijdens het verwerken van het verzoek Netwerk authenticatie vereist - De client moet geauthenticeerd zijn om toegang te krijgen tot het netwerk + NIP-96 servers + Voeg zoveel servers toe als je wilt. Je kunt later kiezen welke zal worden gebruikt bij het uploaden van je afbeelding + Blossom servers + Voeg zoveel servers toe als je wilt. Je kunt later kiezen welke zal worden gebruikt bij het uploaden van je afbeelding Voeg een NIP-96 Server toe + Een Blossom server toevoegen Alles verwijderen Weet u zeker dat u alle concepten wilt verwijderen? Stack: diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index f888c7fe9..35296233b 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -116,6 +116,7 @@ URL awatara URL banera Adres URL strony + Zaimki LN Adres Adres URL LN (nieaktualny) Zapisz w galerii @@ -147,6 +148,8 @@ Lightning Adres Kopiuje Nsec ID (hasło) do schowka w celu wykonania kopii zapasowej Skopiuj tajny klucz do schowka + Pokaż kod QR klucza prywatnego + Pokaż zaszyfrowany kod QR klucza prywatnego Kopiuje klucz publiczny do schowka w celu udostępnienia Skopiuj klucz publiczny (NPub) do schowka Wyślij bezpośrednią wiadomość @@ -183,6 +186,7 @@ Ładowanie konta "Błąd wczytywania odpowiedzi: " Spróbuj ponownie + Brak powiadomień. Brak zawartości. Odśwież stworzył (a) @@ -202,6 +206,8 @@ przetłumaczono z do Najpierw pokaż w %1$s + Temat czatu: %1$s + Temat dyskusji %1$s Zawsze tłumacz na %1$s Nigdy nie tłumacz z %1$s Adres Nostr @@ -332,10 +338,13 @@ Co to oznacza? Ta zawartość jest taka sama od początku Ta zawartość uległa zmianie. Autor mógł nie zobaczyć ani nie zatwierdzić zmiany + Dodaj plik Dodaj zdjęcie Dodaj wideo Dodaj dokument Dodaj do wiadomości + Dodaj nagłówek + Mój przyjacielu Opis zawartości Błękitna łódź na białej piaszczystej plaży o zachodzie słońca Typ Zap-a @@ -349,15 +358,25 @@ Bez Zapów Brak śladu w Nostr, tylko w Lightning Serwer Plików + Wybierz serwer, aby przesłać ten plik LnAdres lub @Użytkownik Serwery Multimediów Ustaw swoje preferowane serwery przesyłania multimediów. - Nie masz ustawionych własnych serwerów multimediów. Możesz użyć listy Amethyst\'a lub dodać jeden poniżej ↓ + Nie masz ustawionych własnych serwerów NIP-96. Możesz użyć listy Amethysta lub dodać jeden poniżej ↓ + Nie masz ustawionych własnych serwerów multimediów. Możesz użyć listy Amethysta lub dodać jeden poniżej ↓ Wbudowane serwery multimediów Domyślna lista Amethyst-a. Możesz dodawać je pojedynczo lub dodać pełną listę. Użyj domyślnej listy Dodaj serwer multimediów Usuń serwer multimediów + Nie rozpoczęte + Kompresja + Wgrywanie + Przetwarzanie + Pobieranie + Hashowanie + Gotowe + Błąd Twoje transmitery (NIP-95) Pliki są przechowywane przez Twoje retransmitery. Nowy NIP: sprawdź, czy jest obsługiwany Ustawienia prywatności @@ -371,6 +390,7 @@ Nie Lista obserwowanych Obserwowane + W pobliżu Wszystkie Zablokowane Domyślny port to 9050 @@ -540,11 +560,16 @@ Sprawdzanie adresu Nostr Zaznacz/Odznacz wszystko Domyślne + Przywróć ustawienia domyślne Wybierz Transmiter, aby kontynuować Przekaż zapy do: Wspierający klient podzieli i przekaże zapy na LNAdres lub profil użytkownika poniżej zamiast Ciebie Wyświetl lokalizację jako Dodaje Geohash twojej lokalizacji do wpisu. Użytkownicy będą wiedzieli, że jesteś mniej niż 5 km od bieżącej lokalizacji + Post ekskluzywny dla lokalizacji + Tylko obserwatorzy z twojej lokalizacji zobaczą to. Twoi ogólni obserwatorzy tego nie zobaczą. + Pobieranie lokalizacji + Brak dostępu do lokalizacji Dodaje ostrzeżenie o wrażliwych treściach przed wyświetleniem treści. Jest to idealne dla dowolnych treści NSFW lub treści, które niektóre osoby mogą uznać za obraźliwe lub przeszkadzające Nowa Funkcjonalność Aktywacja tego trybu wymaga wysłania wiadomości NIP-17 przez Ametyst. (Prezent, Zapieczętowane wiadomości bezpośrednie i grupowe). NIP-17 jest nowy, a większość klientów jeszcze go nie zaimplementowała. Upewnij się, że odbiorca używa kompatybilnego klienta. @@ -707,7 +732,9 @@ Błąd wysyłania: %1$s Serwer nie podał adresu URL po załadowaniu Nie można pobrać przesłanych plików z serwera + Nie można sprawdzić pobranego pliku po wgraniu: %1$s Nie można przygotować pliku lokalnego do przesłania: %1$s + Nie udało się wgrać pliku do %1$s: %2$s Nie udało się przesłać: %1$s Nie udało się usunąć: %1$s Media są zbyt duże dla NIP-95 @@ -769,6 +796,7 @@ Skonfiguruj prywatne transmitery odbiorcze To ustawienie pozwala wszystkim wiedzieć, których transmiterów użyć podczas wysyłania wiadomości do Ciebie. Bez nich możesz przegapić niektóre wiadomości. Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny) + Dobre opcje to:\n - auth.nostr1.com (darmowe)\n - inbox.nostr.wine (płatne)\n - relay.0xchat.com (darmowe) Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Transmitery DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Konfiguruj Transmitery wyszukujące @@ -822,6 +850,7 @@ Zapieczętowana wiadomość włączona. Kliknij, aby wyłączyć zapieczętowaną wiadomość Wyślij Odtwórz nazwę użytkownika jako dźwięk + Pinezka Zeskanuj QR kod Przejdź do Alby zewnętrznego dostawcy portfela Nie jest możliwa odpowiedź na szkic wpisu @@ -864,7 +893,12 @@ Niewystarczająca pojemność - serwer nie ma wystarczającej pojemność, aby pomyślnie przetworzyć żądanie Wykryta pętla - serwer wykrywa nieskończoną pętlę podczas przetwarzania żądania Wymagane uwierzytelnienie sieciowe - Klient musi być uwierzytelniony aby uzyskać dostęp do sieci + Serwery NIP-96 + Dodaj tyle serwerów, ile chcesz. Możesz wybrać, z którego chcesz skorzystać później podczas wgrywania zdjęcia + Serwery Blossom + Dodaj tyle serwerów, ile chcesz. Możesz wybrać, z którego chcesz skorzystać później podczas wgrywania zdjęcia Dodaj serwer NIP-96 + Dodaj Blossom Serwer Usuń wszystko Czy na pewno chcesz usunąć wszystkie wersje robocze? Stos: diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 3563777f3..e99a8327e 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -116,10 +116,13 @@ URL da foto de perfil URL do Banner URL do Site + Pronomes Endereço LN LN URL (desatualizado) Salvar na Galeria Imagem salva para a galeria + O download do vídeo foi iniciado… + O download da mídia foi iniciado… Falha ao salvar a imagem Vídeo salvo na galeria de vídeos do telefone Falha ao salvar o vídeo @@ -147,6 +150,8 @@ Endereço Lightning Copiar a chave Nsec (sua senha) para backup Copiar Chave Privada + Mostrar código QR de chave privada + Mostrar o código QR da chave privada criptografado Copia a chave public para ser compartilhada Copiar chave pública (NPub) Enviar uma mensagem direta @@ -183,6 +188,7 @@ Carregando conta "Erro ao carregar respostas" Tente novamente + Ainda não há notificações. Feed está vazio Atualizar criado @@ -202,6 +208,8 @@ traduzido de para Mostrar em %1$s primeiro + Chat público sobre %1$s + Comunidade pública sobre %1$s Sempre traduzir para %1$s Nunca traduzir de %1$s Endereço Nostr @@ -330,10 +338,13 @@ O que isso significa? A imagem é a mesma desde o post A imagem mudou. O autor pode não ter visto a mudança + Adicionar Mídia Adicionar Imagem Adicionar Vídeo Adicionar Documento Adicionar à mensagem + Adicionar uma legenda + Meu amável amigo Descrição do conteúdo Um barco azul em uma praia de areia branca ao pôr do sol Tipo de Zap @@ -347,15 +358,25 @@ Sem Zap Nenhum traço no Nostr, apenas na Lightning Servidor de arquivos + Escolha um servidor para onde enviar este arquivo LnAddress ou @Usuário Servidores de Mídia Defina seus servidores de upload de mídia preferidos. - Não há nenhum servidor de mídia personalizado. Você pode usar a lista do Amethyst, ou adicionar um abaixo + 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. Usar Lista Padrão Adicionar servidor de mídia Apagar servidor de mídia + Não iniciado + Compactando + Enviando + Processando + Baixando + Hashando + Concluído + Erro Seus relays (NIP-95) Os arquivos são hospedados por seus relays. Novo NIP: verifique se eles suportam Opções de Privacidade @@ -369,6 +390,7 @@ Não Lista de seguidores Seguindo + Perto de mim Global Lista Silenciada Porta padrão é 9050 @@ -538,11 +560,16 @@ Verificando o endereço Nostr Selecionar/desmarcar tudo Padrão + Redefinir para os Padrões Selecione um relay para continuar Encaminhar Zaps para: Os clientes que suportam encaminharão zaps para o endereço lightning ou perfil de usuário abaixo, em vez do seu Expor localização como Adicione um geohash da sua localização à postagem. O público saberá que você está a 5 km (3 milhas) do local atual + Postagem exclusiva de localização + Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso. + Carregando localização + Sem permissões para localização Adiciona aviso de conteúdo sensível antes de mostrar seu conteúdo. Isso é ideal para qualquer conteúdo NSFW ou conteúdo que algumas pessoas possam considerar ofensivo ou perturbador Novo recurso Ativando este modo requer o Amethyst para enviar uma mensagem de NIP-17 (GiftWrapped, Sealed Direct and Group Messages). NIP-17 é novo e a maioria dos clientes ainda não o implementaram. Certifique-se de que o destinatário está usando um cliente compatível. @@ -571,6 +598,8 @@ Copiar URL para a área de transferência Copiar ID da nota para a área de transferência Adicionar Mídia à Galeria + Mídia adicionada + Mídia adicionada à sua Galeria de Perfil Criado em Regras Login com Amber @@ -705,7 +734,9 @@ Erro ao carregar: %1$s O servidor não forneceu uma URL após o upload Não foi possível baixar o arquivo de mídia carregado do servidor + Não foi possível verificar o arquivo baixado após o upload: %1$s Não foi possível preparar o arquivo local para enviar: %1$s + Falha ao acessar %1$s: %2$s Falha ao enviar: %1$s Falha ao excluir: %1$s Mídia muito grande para NIP-95 @@ -767,6 +798,7 @@ Configurar seus relés de Caixa de Entrada Privada Esta configuração permite que todos saibam quais relés usar ao enviar mensagens para você. Sem eles, você pode perder algumas mensagens. Boas opções são:\n - inbox.nostr.wine (pago)\n - you.nostr1.com (relés pessoais - pago) + Opções boas são:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (gratuito) Insira entre 1–3 relés para servir como sua caixa de entrada privada. Relés de Caixa de Entrada de DM devem aceitar qualquer mensagem de qualquer pessoa, mas permitir apenas você a baixá-las. Configurar agora Relés de Pesquisa @@ -820,6 +852,7 @@ Mensagem selada ligada. Clique para desligar a mensagem selada Enviar Reproduzir nome de usuário como áudio + Piones Escanear código QR Navegar para o provedor de carteira de terceiros Alby Não é possível responder uma nota em rascunho @@ -862,7 +895,12 @@ Armazenamento insuficiente - O servidor não possui armazenamento suficiente para processar a requisição com sucesso Loop detectado - O servidor detecta um loop infinito enquanto processa a requisição Autenticação de rede necessária - O cliente deve ser autenticado para acessar a rede + Servidores NIP-96 + Adicione quantos servidores você desejar. Você pode escolher qual usar mais tarde quando enviar sua foto + Servidores de Blossom + Adicione quantos servidores você desejar. Você pode escolher qual usar mais tarde quando enviar sua foto Adicionar um servidor NIP-96 + Adicionar um Servidor de Blossom Apagar tudo Você tem certeza de que deseja excluir todos os rascunhos? Pilha: diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 6ab29c703..c0be026f5 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -1,42 +1,920 @@ + Usmeri v QR kodo + Pokaži QR kodo + Profilna slika + Tvoja profilna slika + Skeniraj QR kodo + Vseeno prikaži + To sporočilo je skrito, ker je omenjena nekatera od skritih uporabnikov ali besed + Sporočilo je bilo utišano ali prijavljeno s strani + Dogodek se nalaga ali pa ni najden v tvojih relejih + 👀 + Slika kanala + Referenčni dogodek ni najden + Dešifriranje sporočila ni uspelo + Slika skupine + Eksplicitna vsebina + Nezaželjena vsebina + Iz tega releja izvira številna nezaželjena vsebina + Oponašanje + Nedovoljeno obnašanje + Drugo + Neznano + Ikona releja + Avtor neznan + Kopiraj tekst + Kopiraj ID avtorja + Kopiraj ID zapiska + Oddajanje + Uporabi časovni žig + Časovni žig: Potrditve v obdelavi + OTS: V obdelavi + Zaprosi za izbris + Blokiraj / Prijavi + + Prijavi nazaželjeno vsebino / prevaro + Prijavi oponašalca + Prijavi eksplicitno vsebino + Prijavi nedovoljeno obnašanje + Zlonamerna programska vsebina + Prijavi moderatorja + Zlonamerna programska vsebina Administrator + Uporabljaš javni ključ in javni ključi omogočajo le branje. +Prijavi se s privatnim ključem, da omogočiš tudi pisanje + Uporabljaš javni ključ in javni ključi omogočajo le branje. +Prijavi se s privatnim ključem za posredovanje sporočila Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem za všečkanje sporočila + Nimaš nastavljene Zap vsote. Za spremembo uporabi daljši pritisk + Uporabljaš javni ključ in javni ključi omogočajo le branje. +Prijavite se s privatnim ključem, da boste lahko pošiljali Zape + Uporabljaš javni ključ in javni ključi omogočajo le branje. +Prijavi se s privatnim ključem, za sledenje uporabnika + Uporabljaš javni ključ in javni ključi omogočajo le branje. +Prijavi se s privatnim ključem, za odstranitev iz seznama sledljivih + Uporabljaš javni ključ in javni ključi omogočajo le branje. +Prijavi se s privatnim ključem, za cenzuro besed ali stavkov + Uporabljaš javni ključ in javni ključi omogočajo le branje. +Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov + Zapi Števec vpogledov - "" - + Posreduj + posredovano + posodobljeno + uredi #%1$s + original + Citiraj + Razcepi + Prošnja za urejanje + Nova vrednost v Sat + Dodaj + "odgovoriti " + " in " + "v kanalu " + Profilna pasica + Plačilo uspešno + Napaka pri razčlembi sporočila o napaki + " Sledim" + " Sledilcev" + Profil + Varnostni filtri + Odjavi se + Pokaži več + Lightning faktura + Plačaj + Lightning napitnine + Zapis za sprejetje + Najlepša hvala! + Vsota v Sat + Pošlji Sat + "Napaka pri razčlembi predogleda za %1$s : %2$s" + "Predogled kartne slike za %1$s" + Nov kanal + Ime kanala + Moja vrhunska skupina + Url slike + Opis + "O nas.. " + Kaj imaš v mislih? + Pošlji + Shrani + Ustvari + Prekliči + Neuspelo nalaganje slike + Naslov releja + Pošlji + Bajti + Napake + Število napak pri povezovanju v tej seji + Domači vir novic + Vir privatnih sporočil Javni vir novic + Globalni vir + Išči vir Dodaj rele - Prikazno ime + Prikazano ime + Moje prikazno ime + Janez Slovenc + Dobrodošel Nojek! + Uporabniško ime + Moje uporabniško ime + O meni + URL avatarja + URL pasice + URL spletne strani + Zaimki + LN Naslov + Zastarel LN (lightning) URL + Shrani v galerijo + Slika shranjena v foto galerijo telefona + Neuspešno shranjevanje slike + Video shranjen v video galerijo telefona + Neuspešno shranjevanje videa + Naloži sliko Nalaganje … + Uporabnik nima nastavljenega \"lightning\" naslova za sprejem satoshi-jev + "odgovori tukaj.. " + Kopira ID zapiska v odložišče za deljenje v Nostr + Kopiraj ID kanala (zapisek) v odložišče + Uredi metapodatke kanala + Pridruži se + Znano + Nova prošnja + Blokirani uporabniki + Nove objave + Pogovori + Zapiski + Pogovori + Galerija + "Sledim" + "Reportaže" + Več možnosti + " Releji" + Spletna stran + Lightning naslov + Kopira Nsec ID (tvoje geslo) v odložišče za varnostno kopiranje + Kopiraj privatni ključ v odložišče + Pokaži QR kodo zasebnega ključa + Pokaži QR kodo šifriranega zasebnega ključa + Kopira javni ključ v odložišče za deljenje + Kopiraj javni ključ (NPub) v odložišče + Pošlji direktno sporočilo + Uredi uporabnikove metapodatke + Sledi + Sledi nazaj + Odblokiraj + Kopiraj uporabnikov ID + Odblokiraj uporabnika "npub, uporabniško ime, tekst" + Počisti + Logo aplikacije + nsec.. ali npub.. + geslo za odpiranje ključa + Prikaži geslo + Skrij geslo + Neveljaven ključ + Neveljaven ključ: %1$s + "Jaz sprejemam " + pogoji uporabe + Potrebno je sprejetje pogojev + Potrebno je geslo + Potreben je ključ + Potrebno je ime + Prijavi se + Vpiši se + Ustvari račun + Kako naj te kličemo? + Še nimaš Nostr računa? + Že imam Nostr račun? + Ustvari nov račun + Zgeneriraj nov ključ + Vir se nalaga + Račun se nalaga + "Napaka pri nalaganju odgovorov: " + Poskusi ponovno + Ni še obvestil. + Vir je prazen. + Osveži + ustvarjeno + z opisom + in sliko + ime kanala spremenjeno v + opis za + in fotografijo + Zapusti + Prenehaj slediti + Kanal ustvarjen + "Informacije kanala spremenjene v" + Javni pogovor + sprejete objave + Odstrani + Avtomatsko + prevedeno iz + v + Prikaži najprej v %1$s + Javni pogovor o %1$s + Javna skupnost o %1$s + Vedno prevedi v %1$s + Nikoli ne prevedi iz %1$s + Nostr naslov t. i. Nip-05 + nikoli + zdaj + h + m + d + Golota + Psovke / Sovražni govor + Prijavi sovražni govor + Prijavi goloto / pornografijo + drugo + Označi vse znane kot prebrane + Označi vse nove kot prebrane + Označi vse kot prebrane + Ustvari varnostno kopijo ključev + ## Varnostna kopija ključev in varnostni nasveti + \n\nTvoj račun je zaščiten z privatnim ključem. Ta ključ ima dolgo sekvenco znakov kateri se začnejo z **nsec1**. Kdorkoli ima dostop, do tega privatnega ključa, lahko pošiljajo zapiske in spremenijo tvojo digitalno identiteto. + \n\n- Nikoli in **Nikdar** ne vpisuj svojega privatnega ključa v spletne strani in aplikacije katerim ne zaupaš. + \n- Amethyst ustvarjalci te ne bojo **nikoli** vprašali po tvojem privatnem ključu. + \n- Ustvari **varnostno kopijo** svojega privatnega ključa, za obnovitev računa. Priporoča se uporaba upravljalnika gesel (password manegerja). + + Za dodatno varnost, lahko šifriraš svoj ključ z dodatnim geslom. Ta ključ se začne z **ncryptsec1** in ne more bit uporabljen brez dodatnega gesla. + \n\nČe izgubiš geslo, obnova ključa ni več mogoča. + + Neuspelo dešifriranje tvojega privatnega ključa + Privatni ključ (nsec) kopiran v odložišče + Kopiraj moj privatni ključ + Šifriraj in kopiraj moj privatni ključ + Neuspela overitev + Neuspela overitev lastnika telefona z biometrijo + Neuspela overitev lastnika telefona z biometrijo. Napaka: %1$s + Napaka + "Ustvaril/a %1$s" + "Slika nagradne značke za %1$s" + Prejel/a si novo nagradno značko + Nagradna zančka podeljena + Tekst zapiska kopiran v odložišče + Avtorjev @npub kopiran v odložišče + Zapiskov ID (@note1) kopiran v odložišče + Izberi tekst + "<Unable to decrypt private message>\n\nOmenjeni ste bili v zasebnem/šifriranem pogovoru med %1$s in %2$s." + Dodaj nov račun + Računi + Izberi račun + Dodaj nov račun + Aktivni račun + Ima privatni ključ + Samo za branje, brez privatnega ključa + Nazaj + Izberi Deli povezavo brskalnika + Deli + Avtorjev ID + ID zapiska + Kopiraj tekst + Izbriši + Prenehaj slediti + Sledi Izbris iz galerije + Odstrani ta medij iz tvoje galerije, lahko ga dodaš nazaj kasneje + Prošnja za izbris + Amethyst bo poslal prošnjo za izbris zapiska vsem relejem na katere ste povezani. Nobenega jamstva ni, da bo vaš zapisek trajno izbrisan iz teh relejev ali iz drugih relejev, kjer je morda shranjen. Blokiraj + Izbriši + Blokiraj + Prijavi Izbriši Ne prikaži ponovno + Nezaželjena vsebina ali prevara + Nesramnost ali sovražno vedenje + Zlonamerno lažno predstavljanje + Golota ali grafična vsebina + Nelegalno obnašanje + Zlonamerna programska oprema / vsebina + Blokiranje uporabnika bo skrilo njegove objave v tvoji aplikaciji. Tvoji zapiski še vedno ostajajo javno dostopni. Blokirani uporabniki so vidni v Varnost in filtri. + + Prijavi zlorabo + Vse poslane prijave bojo javno vidne + Opcijsko lahko dodaš dodatna pojasnila glede tvoje prijave… + Dodaten kontekst Razlog + Izberi razlog… + Objavi prijavo + Blokiraj in prijavi + Blokiraj + Zaznamki + Osnutki + Privatni zaznamki + Javni zaznamki + Dodaj v privatne zaznamke + Dodaj v javne zaznamke + Odstrani iz privatnih zaznamkov + Odstrani iz javnih zaznamkov + Wallet Connect storitev + Pooblasti Nostr skrivnost (Nostr secret) za plačevanje z Zapi brez zapuščanja aplikacije. Nostr skrivnost (Nostr secret) hranite na varnem in, če je mogoče, uporabite zasebni rele + Wallet Connect javni ključ + Wallet Connect rele + Wallet Connect skrivnost + Prikaži skrivni ključ + nsec / hex privatni ključ + Prispevaj vsoto v sat + Pošlji anketo + Zahtevana polja: + Prejemniki Zapov + Opis primarne ankete… + Izbira %s + Opis izbire v anketi + Izbirna polja: + Zap minimum + Zap maksimum + Soglasje + (0–100)% Zapri po + dni + Nezmožen za glasovanje + Anketa je zaprta za nove glasove + Zap vsota + Dovoljen je le en glas na uporabnika pri tem tipu ankete + "Iskanje dogodka %1$s" + Dodaj javno sporočilo + Dodaj privatno sporočilo + Dodaj sporočilo k fakturi + Hvala za vso tvoje delo! + Ustvari in dodaj + Avtorji anket ne morejo glasovati v svojih anketah. + Kaj to pomeni? + Ta vsebina je ista od objave + Ta vsebina je bila spremenjena. Avtor morda ni videl ali dovolil spremembe + Dodaj medij + Dodaj sliko + Dodaj video + Dodaj dokument + Dodaj v sporočilo + Dodaj napis + Moja draga družba + Opis vsebine + Modra jadrnica pri beli peščeni plaži ob sončnem zahodu + Vrsta Zap-a + Vrsta Zap-a za vse možnosti + Javno + Vsi lahko vidijo transakcijo in sporočilo + Privatno + Pošiljatelj in prejemnik lahko vidita drug drugega in prebereta sporočilo + Anonimno + Prejemnik in javnost ne vejo kdo je poslal plačilo + Ni-Zap + Brez sledi v Nostr, samo v Lightning + Datotečni strežnik + Izberi strežnik za nalaganje te datoteke + LnNaslov ali @Uporabnik + Medijski strežniki + Nastavi svoje prednostne medijske strežnike za nalaganje medijev. + Nimate nastavljenih NIP-96 strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓ + Nimate nastavljenih Blossom strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓ + Vgrajeni medijski strežniki + Privzeti seznam Amethysta. Lahko jih dodate posamično ali dodate celoten seznam. + Uporabi privzeti seznam + Dodaj medijski strežnik + Odstrani medijski strežnik + Čakam na začetek + Zgoščujem + Nalagam + Obdelujem + Prenašam + Razpršujem + Končano + Napaka + Tvoji releji (NIP-95) + Datoteke so gostovane preko tvojih relejev. Nov NIP: Preveri če podpirajo + Zasebnostne nastavitve + Tor/Orbot nastavitve + Poveži se preko tvojih Orbot nastavitev + Prilagodi + Tor nastavitve + Prekini povezavo od tvojega Orbot/Tor? + Vaši podatki bodo takoj preneseni v običajno omrežje + Da + Ne + Spisek komu sledimo + Vse, čemur sledimo + V moji okolici Globalno + Spisek utišanih + Prevzeta vrata so 9050 + ## Poveži se preko Tor omrežja z Orbot aplikacijo + \n\n1. Namesti [Orbot aplikacijo](https://play.google.com/store/apps/details?id=org.torproject.android) + \n2. Zaženi Orbot aplikacijo + \n3. V Orbot aplikaciji, preveri \"Socks port\". Pradnastavitev uporablja vrata 9050 + \n4. Po potrebi spremenite vrata v Orbot aplikaciji + \n5. Prilagodi vrata na tem zaslonu + \n6. Pritisni gumb aktiviraj za uporabo Orbot aplikacije kot proxy + + Orbot socks vrata + Aktivni Tor stroj + Uporabi interno različico ali Orbot + Prednastavitve zasebnosti + Hitro spremeni vse spodnje nastavitve + Onion Url/Releji + Uporabi Tor za katerikoli .onion url + Rele ZS + Prisili tor za pošiljanje in sprejemanje ZS + Nezaupanja vredni releji + Prisili tor pri relejih za odhodno/prejeto pošto + Zaupanja vredni releji + Prisili Tor na vseh relejih v tvojem spisku + Profilne slike Prisili tor pri nalaganju profilnih slik - Končaj + URL predogledi + Prisili tor pri nalaganju url predogledov + Slike + Prisili tor pri nalaganju slik + Video posnetki + Prisili tor ob nalaganju video posnetkov + Denarne operacije + Prisili tor pri Zapih, Lightning in Cashu plačilnih prenosih + Preveritev Nostr naslova + Prisili tor ob preverjanju NIP-05 naslovov + Nalaganje medijske vsebine + Prisili tor pri nalaganju vsebine + Interno + Orbot + Izključeno + Osnovno + Privzeto + Vse razen mediji + Popolna zasebnost + Lastne nastavitve + Uporabi Tor, ko je ta potreben za strežnik + Skrij svoj IP pred naključnimi releji + Skrij svoj IP pred vsem, razen pred slikami in video vsebini + Skrij svoj IP v vseh povezavah + Ustvari svojega + Neveljavna številka vrat + Uporabi Orbot + Odklopi Tor/Orbot + Zasebna sporočila + Ob prejemu zasebnega sporočila te obvesti + Prejeti Zapi + Vas obvesti, ko vas nekdo Zap-ne + %1$s sat + Od %1$s + za %1$s + Obvesti: + Pridruži se pogovoru + ID uporabnika ali skupine + npub, nevent ali hex + Ustvari + Pridruži se + Danes + Opozorilo o vsebini + Ta objava vsebuje občutljivo vsebino, ki jo lahko nekateri smatrajo za žaljivo ali vznemirjajočo + Vedno skrij občutljivo vsebino + Vedno prikaži občutljivo vsebino + Vedno prikaži opozororilo o vsebini + Priporoča: + Filtriraj nezaželjeno vsebino od neznancev + Opozori, ko objave vsebujejo prijave od oseb, ki jim slediš + Nov reakcijski simbol + Za tega uporabnika niso predhodno izbrane nobene vrste reakcij. Dolgo pritisnite na gumb za srce, da jih spremenite + Zapraiser + Doda ciljni znesek v sat, za zbiranje donacij v tej objavi. Podprti Nostr odjemalci lahko ta cilj prikažejo kot vrstico napredka za spodbujanje donacij + Ciljni znesek v sat + Zapraiser na %1$s. %2$s satoshi-jev do cilja + Beri iz releja + Piši v rele + Količina bajtov, ki je bila poslana temu releju, vključno s filtri in dogodki + Količina bajtov, ki je bila prejeta od tega releja, vključno s filtri in dogodki + Prišlo je do napake pri pridobivanju informacij o releju iz %1$s + Lastnik + Različica + Program + Kontakt + Podprti NIP-i + Vstopnina + URL za plačila + Omejitve + Države + Jeziki + Oznake + Pravila objavljanja + Napake in obvestila s tega releja + Dolžina sporočila + Naročnine + Filtri + Dolžina id naročnine + Minimalna predpona + Maksimum oznak v dogodku + Dolžina vsebine + Minimalen PoW + Auth + Plačilo + Cashu žeton + Unovči + Pošlji v Zap denarnico + Odpri Cashu denarnico + Kopiraj žeton + Lightning naslov ni nastavljen + Žeton kopiran v odložišče + V ŽIVO + ODKLOPLEN + KONČANO + PLANIRANO + Prenos v živo je odklopljen + Prenos v živo je končan + Odjava bo izbrisala vse vaše lokalne informacije. Poskrbite, da imate varnostno kopijo svojih zasebnih ključev, da se izognete izgubi računa. Ali želite nadaljevati? + Spremljana vsebina + Releji + Odkrivanje zapiskov + Tržnica + V živo + Skupnosti + Pogovori + Odobrene objave + Ta skupina nima opisa ali pravil. Obrnite se na lastnika, da jih doda + Ta skupnost nima opisa. Obrnite se na lastnika, da ga doda + Občutljiva vsebina + Doda opozorilo o občutljivi vsebini pred prikazom te vsebine + Nastavitve aplikacije + Nastavitve + Vedno + Samo Wifi + Samo z WiFi + Nikoli + Celovit + Poenostavljen + Optimiziran + Sistemska + Svetla + Temna + Nastavitve aplikacije + Wallet Connect + Jezik + Tema + Predogled slike + Predvajaj Video + Predogled URL-ja + Poglobljeno pomikanje + Skrij navigacijsko vrstico ob pomikanju + UI način + Izberi stil objave + Naloži sliko + Pošiljatelji nezaželjenih vsebin + Utišano. Klikni za vklop zvoka Zvok je prižgan. Klikni da ga utišaš + Išči po lokalnih in zunanjih arhivskih zapisih + Nostr naslov je preverjen + Preverjanje Nostr naslova ni uspelo + Preverjanje Nostr naslova + Izberi/Prekliči izbiro vsega + Privzeto + Ponastavi na privzeto + Izberi rele za nadaljevanje + Posreduj Zape k: + Podprti Nostr odjemalci bodo Zape preusmerili na spodnji LN-naslov ali uporabniški profil, namesto na vašega + Prikaži lokacijo kot + Doda Geohash vaše lokacije k objavi. Javnost bo vedela, da se nahajate v radiju 5 km (3 milj) od trenutne lokacije + Lokacijsko-ekskluzivna objava + To bodo videli samo sledilci lokacije. Vaši splošni sledilci tega ne bodo videli. + Nalaganje lokacije + Brez dovoljenj za lokacijo + Doda opozorilo o občutljivi vsebini pred prikazom vaše vsebine. To je primerno za vsebino NSFW ali vsebino, ki jo nekateri lahko smatrajo za žaljivo ali vznemirjajočo + Nova funkcija + Za aktivacijo tega načina mora Amethyst poslati sporočilo NIP-17 (GiftWrapped, šifrirana neposredna in skupinska sporočila). NIP-17 je nov in večina Nostr odjemalcev ga še ni implementirala. Prepričajte se, da prejemnik uporablja združljiv Nostr odjemalec. + Aktiviraj + Javno + Nova javna ali zasebna skupina + Zasebno + Za + Zadeva + Tema pogovora + "\@Uporabnik1, @Uporabnik2, @Uporabnik3" + Člani te skupine + Razlaga članom + Spreminjanje imena za dosego novih ciljev. + Prilepi iz odložišča + Za vmesnik aplikacije + Temna, svetla ali sistemska tema + Samodejno naloži slike in GIF-e + Samodejno predvajaj videe in GIF-e + Prikaži predogled URL-jev + Kdaj naložiti slike + Kopiraj Stack + Kopiraj v odložišče + Kopiraj npub v odložišče + Deli ali shrani + Kopiraj URL v odložišče + Kopiraj ID zapiska v odložišče + Dodaj medijsko vsebino v galerijo + Ustvarjeno ob + Pravila + Prijavi se z Amber + Posodobi svoj status + Napaka pri razčlembi sporočila o napaki + Glasovi so ovrednoteni glede na količino Zapov. Lahko nastavite minimalni znesek, da se izognete neželeni pošti, in največji znesek, da preprečite, da bi bogatejši volilci prevzeli glasovanje. V obeh poljih uporabite enak znesek, da zagotovite, da je vsak glas enako ovrednoten. Pustite prazno, če želite sprejeti poljubno količino. + Zap ni uspel + Pošlji sporočilo uporabniku + OK + Neuspela povezava z %1$s: %2$s + Ni uspelo sestaviti URL-ja NIP-11 za %1$s: %2$s Neuspela povezava z %1$s: %2$s Neuspešna analiza odgovora %1$s: %2$s - Cashu-ja ni bilo mogoče unovčiti + Rele zavrnil zahtevo %1$s: %2$s + Ni uspelo vzpostaviti povezave z %1$s: %2$s + Ni uspelo razčleniti rezultata od %1$s: %2$s + %1$s ni uspelo s kodo %2$s + Aktiven: + Domov + ZS + Pogovori + Globalno + Išči + Razdeli in posreduj Zape + Podprti Nostr odjemalci bojo razdelili in posredovali Zap-e, uporabnikom dodanim tukaj namesto k vam + Išči in dodaj uporabnika + Uporabniško ime ali prikazano ime + Manjka \"lightning\" nastavitev + Uporabnik %1$s nima nastavljenega \"lightning\" naslova za prejemanje satoshi-jev + Odstotek + 25 + Razdeli Zape z + Posreduj Zape + Ne najdem \"lightning\" denarnic + Plačano + Denarnica %1$s + Napaka pri odpiranju aplikacije za podpisovanje + Aplikacije za podpisovanje ni bilo mogoče najti. Preverite, ali aplikacija morda, ni bila odstranjena + Podpisovanje je bilo zavrnjeno + Prepričajte se, da je aplikacija za podpisovanje odobrila to transakcijo + Denarnica za plačilo z \"lightning\" ni najdena (Napaka: %1$s). Prosimo, namestite \"lightning\" denarnico da boste lahko Zap-ali ostale + Denarnica za plačilo z \"lightning\" ni najdena. Prosimo, namestite \"lightning\" denarnico da boste lahko Zap-ali ostale + Skrite besede + Skrij novo besedo ali stavek + Profilna slika + Prikaži profilne slike + Uporabi možnost + Fakture ni bilo mogoče plačati + Ni bilo mogoče dvigniti + Ni bilo mogoče nastaviti \"Wallet Connect\" + Napaka pri razčlenjevanju NIP-47 connection string. Preverite pri vašem ponudniku denarnice, ali je to pravilno: %1$s. Napaka: %2$s + Napaka pri razčlenjevanju NIP-47 connection string. Preverite pri vašem ponudniku denarnice, ali je to pravilno: %1$s. + Cashu žetona ni bilo mogoče unovčiti + Kovnica je posredovala naslednje sporočilo o napaki: %1$s + Cashu žeton je že zapravljen. + Prejeli ste Cashu žeton + V vašo denarnico je bilo poslanih %1$s satoshi-jev. (Provizija: %2$s sat) Na sistemu ni najdene združljive Cashu denarnice + Ni mogoče pridobiti fakture s strežnikov prejemnika + Vaš \"Wallet Connect\" ponudnik je vrnil naslednjo napako: %1$s + Ni bilo mogoče povezati s Tor + Prenos rele dokumentacije ni na voljo + Ni bilo mogoče sestaviti LNUrl iz \"lightning\" naslova \'%1$s\'. Preverite uporabnikove nastavitve Prejemnikova \"lightning\" storitev na %1$s ni na voljo. Izračunana je bila iz \"lightning\" naslova \'%2$s\'. Napaka: %3$s. Preverite, ali strežnik deluje in ali je \"lightning\" naslov pravilen + Ni bilo mogoče razrešiti %1$s. Preverite, ali ste povezani, ali je strežnik dosegljiv in ali je lightning naslov %2$s pravilen Ni bilo mogoče razrešiti %1$s. Preverite, ali ste povezani, ali je strežnik dosegljiv in ali je lightning naslov %2$s pravilen.\n\nIzjema je bila: %3$s + Ni bilo mogoče pridobiti fakture od %1$s. + Napaka pri razčlenjevanju JSON iz \"lightning\" naslova. Preverite uporabnikove nastavitve za \"Lightning\" + Napaka pri razčlenjevanju JSON iz %1$s. Preverite uporabnikove nastavitve za \"lightning\" + Povratnega URL-ja ni bilo mogoče najti v konfiguraciji strežnika za lightning naslov uporabnika + Povratni URL ni bil najden v odzivu od %1$s + Napaka pri razčlenjevanju JSON med pridobivanjem fakture iz \"lightning\" naslova. Preverite uporabnikove nastavitve za \"Lightning\" Napaka pri razčlenjevanju JSON med pridobivanjem fakture iz %1$s. Preverite uporabnikove nastavitve za \"Lightning\" + Napačen znesek fakture (%1$s sat) od %2$s. Moral bi biti %3$s + Ni mogoče ustvariti lightning računa pred Zap-anjem. Lightning denarnica prejemnika je poslala naslednjo napako: %1$s + Ni mogoče ustvariti lightning računa. Sporočilo od %1$s: %2$s. + Ni mogoče ustvariti lightning računa pred Zap-anjem. Element \'pr\' ni bil najden v JSON-u. + Ni mogoče ustvariti lightning računa od %1$s: Element \'pr\' ni bil najden v JSON-u. + Uporabnik samo za branje + Ni nastavitev za reakcije Izberi \"UnifiedPush\" aplikacijo + Potisna obvestila + Od nameščenih \"UnifiedPush\" aplikacij + None + Onemogoči potisna obvestila + Uporablja aplikacijo %1$s + Nastavitev potisnih obvestil Za prejemanje potisnih obvestil namestite aplikacijo, ki podpira [Unified Push](https://unifiedpush.org/), na primer [Ntfy](https://ntfy.sh/). Po namestitvi izberite aplikacijo, ki jo želite uporabljati, v nastavitvah. + Sporočilo od %1$s + Nit + Pošlji prodajalcu sporočilo + Živjo %1$s, je to še na voljo? + Živjo, je to še na voljo? + Prodaj stvar + Naslov + Nokia 5210 + Stanje + Kategorija + Cena (v Satoshi) + 1000 + Lokacija + Mesto, Regija, Država + Novo + Novo, v originalni škatli + Kot novo + Rabljeno vendar brez znakov uporabe + Dobro + Ima nekaj znakov uporabe + Zadovoljivo V zadovoljivem stanju Oblačila + Pripomočki + Elektronika + Pohištvo + Zbirateljstvo + Knjige + Živali + Šport + Fitnes + Umetnost Rokodelstvo + Dom + Pisarna + Hrana + Razno Drugo - Osnutkom zapisa ni mogoče pošiljati mikro plačil (zap) + Nalaganje medija ni uspelo + Stisnjene datoteke ni bilo mogoče odpreti + Napaka pri stiskanju medija: %1$s + Napaka pri nalaganju: %1$s + Strežnik po nalaganju ni posredoval URL naslova + Prenos naloženega medija s strežnika ni uspel + Ni bilo mogoče preveriti prenesene datoteke po nalaganju: %1$s + Lokalne datoteke ni bilo mogoče pripraviti za nalaganje: %1$s + Nalaganje na %1$s ni uspelo: %2$s + Nalaganje ni uspelo: %1$s + Izbris ni uspel: %1$s + Medij je prevelik za NIP-95 + Predogleda ni mogoče naložiti + Ni bilo mogoče pripraviti informacij glave: %1$s + Stiskanje preklicano + Stiskanje ni vrnilo datoteke + Kakovost medija + Izberite Nizko kakovost, da stisnete svoj medij v manjšo datoteko z nižjo kakovostjo, Visoko kakovost, da stisnete v večjo datoteko z višjo kakovostjo, ali Nestisnjeno, da naložite medij brez stiskanja. + Nizka + Srednja + Visoka + Nestisnjeno + Uredi osnutek + Vpiši se z QR kodo + Pot + Domov + Iskanje + Razišči + Sporočila + Obvestila + Globalno + Kratki mediji + Varnostni filtri + Nova objava + Novi kratki mediji: slike ali posnetki + Novo zapisek skupnosti + Odpri vse odzive na to objavo + Zapri vse odzive na to objavo + Odgovori + Posreduj ali citiraj + Všečkaj + Zap + Spremeni hitre odzivne ikone + Profilna slika %1$s + Rele %1$s + Razširi seznam relejev + Možnosti zapiska + Izbirnik seznama relejev + Anketa + Onemogoči anketo + Bitcoin faktura + Prekliči Bitcoin fakturo + Prekliči prodajo + Zapraiser + Prekliči Zapraiser + Lokacija + Odstrani lokacijo + Zap razdelitev + Prekliči Zap razdelitev + Dodaj opozorilo o vsebini + Odstrani opozorilo o vsebini + Prikaži npub kot QR kodo + Neveljaven naslov + Amethyst je prejel zahtevo za odprtje URI-ja, vendar je bil ta URI neveljaven: %1$s + Releji predala zasebnih sporočil + Releji: %1$s + Uporaba običajnih relejev + Nastavite releje za predal zasebnih sporočil + Ta nastavitev omogoča vsem, da vedo, katere releje uporabiti, ko vam pošiljajo sporočila. Brez njih bi lahko zgrešili nekatera sporočila. + Dobre možnosti so:\n - inbox.nostr.wine (plačljiv)\n - auth.nostr1.com (brezplačen)\n - you.nostr1.com (osebni releji - plačljivi) + Dobre možnosti so:\n - auth.nostr1.com (brezplačen)\n - inbox.nostr.wine (plačljiv)\n - relay.0xchat.com (brezplačen) + Vnesite 1–3 releje, ki bodo služili kot vaš predal + za zasebna sporočila. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos. + Nastavi zdaj + Iskalni releji + Nastavi iskalne releje + Ustvarjanje seznama relejev, posebej zasnovanega za iskanje in označevanje uporabnikov, bo izboljšalo te rezultate. + Vnesite 1–3 releje za iskanje vsebine ali označevanje uporabnikov. Prepričajte se, da vaši izbrani releji podpirajo NIP-50 + Dobre možnosti so\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Nastavitve relejev + Javni za odhajajočo pošto/domači releji + Ta tip releja shranjuje vse vaše vsebine. Amethyst bo sem pošiljal vaše objave, ostali uporabniki pa bodo te releje uporabljali za iskanje vaših vsebin. Vstavite 1–3 releje. To so lahko osebni releji, plačljivi releji ali javni releji. + Javni releji za dospelo pošto + Ta tip releja sprejema vsa odgovore, komentarje, všečke in zap-e na vaše objave. Ti releji so lahko plačljivi ali brezplačni. Omejitve, ki jih nastavi upravljavec releja, lahko omejijo obvestila, ki jih prejemate, tako v dobrem kot v slabem. Na primer, če ste tarča napada s spam komentarji, ki so nezaželeni, lahko plačljivi releji te nezaželene komentarje odstranijo. Vnesite med 1–3 releje. + Releji predala zasebnih sporočil + Vnesite 1–3 releje, ki bodo služili kot vaš predal za zasebna sporočila. Drugi bodo te releje uporabljali za pošiljanje zasebnih sporočil vam. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos. Dobre možnosti so:\n - inbox.nostr.wine (plačljiv)\n - auth.nostr1.com (brezplačen)\n - you.nostr1.com (osebni releji - plačljivi) + Zasebni domači releji + Vnesite 1–3 releje za shranjevanje dogodkov, ki jih nihče drug ne more videti, kot so vaši osnutki in/ali nastavitve aplikacije. Idealno je, da so ti releji bodisi lokalni ali zahtevajo preverjanje pristnosti pred prenosom vsebine posameznega uporabnika. + Splošni releji + Amethyst uporablja te releje za prenos objav za vas. + Priporočljivi releji + Dodajte naslednje releje na svoj seznam Splošnih relejev, da boste prejeli objave od navedenih uporabnikov. + Iskalni releji + Seznam relejev za uporabo pri iskanju vsebine ali uporabnikov. Označevanje in iskanje ne bosta delovala, če možnosti niso na voljo. Prepričajte se, da podpirajo NIP-50. + Lokalni releji + Seznam relejev, ki delujejo na tej napravi. + Zap-ni ustvarjalce! + Vaša donacija nam pomaga narediti spremembo. Vsak satoši šteje! + Doniraj sedaj + za vas pripravil: + To različico so za vas pripravili: + Različica %1$s + Hvala! + Največji Limit + Omejeni zapis + Razcepljeno od + RAZCEP + Git skladišče: %1$s + Splet: + Klon: + OTS: %1$s + Dokaz časovnega žiga + Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas. + Uredi objavo + Prošnja za izboljšavo objave + Povzetek sprememb + Hitri popravki… + Sprejmi predlog + Prenesi + Vklopi besedilo + Izklopi besedilo + Zapečateno sporočilo je izklopljeno. Kliknite, da vklopite zapečateno sporočilo. + Zapečateno sporočilo je vklopljeno. Kliknite, da izklopite zapečateno sporočilo. + Pošlji + Predvajaj uporabniško ime + Pushpin + Skeniraj QR kodo + Pojdite na stran tretjega ponudnika denarnice Alby. + Ni mogoče odgovoriti na osnutek zapiska. + Ni mogoče citirati osnutka zapiska. + Ni mogoče reagirati na osnutek zapiska. + Osnutke zapisa ni mogoče Zap-niti + Osnutek zapiska + Iz zapiska + Iščem aplikacijo + Zahteva poslana, čakam na odgovor + Zahtevam delo od DVM + Zahteva za plačilo poslana, čakam na potrditev iz vaše denarnice. + Čakam, da DVM potrdi plačilo ali pošlje rezultate. + Neveljavna zahteva - Strežnik ne more ali noče obdelati zahteve. + Nepooblaščeno - Uporabnik nima veljavnih overitvenih podatkov + Potrebno je plačilo - Strežnik zahteva plačilo za dokončanje zahteve + Prepovedano - Uporabnik nima pravic dostopa za izvedbo zahteve + Ni najdeno - Strežnik ne najde zahtevanega naslova + Nedovoljena metoda - Strežnik podpira metodo zahteve, vendar ne ciljnega vira + Ni sprejemljivo – Strežnik ni našel nobene vsebine, ki bi ustrezala zahtevi. + Zahtevana je zastopniška overitev - Uporabnik nima veljavnih overitvenih podatkov + Zahteva je potekla – Strežnik je predolgo čakal na nekoga drugega + Spor – Strežnik ne more izpolniti zahteve zaradi konflikta z virom + Izbris – Zahtevana vsebina je bila trajno izbrisana s strežnika in ne bo obnovljena + Zahtevana dolžina – Strežnik zavrnil zahtevo, ker zahteva določeno dolžino + Predpogoj ni izpolnjen – Zahtevani predpogoji v glavi zahteve niso izpolnjeni s strani strežnika + Tovor je prevelik – Zahteva presega omejitve, določene s strani strežnika, zato jo strežniška obdelava zavrača + URI je predolg – URL, ki ga zahteva odjemalec, je predolg, da bi ga strežnik lahko obdelal. + Nepodprta vrsta medija – Zahteva uporablja medijski format, ki ga strežnik ne podpira. Razpon ni zadovoljiv – Strežnik ne more izpolniti vrednosti, navedene v \"request’s Range header\" polju. + Neizpolnjeno pričakovanje – Strežnik ne more izpolniti zahtev, navedenih v glavi zahteve \"Expect\" + Potrebna nadgradnja – Strežnik zavrača obdelavo zahteve z uporabo trenutnega protokola, razen če odjemalec ne preklopi na drug protokol. + Notranja napaka strežnika – Strežnik je naletel na nepričakovano napako in ne more izpolniti zahteve + Ni implementirano – Strežnik ne more izpolniti zahteve ali ne prepozna metode zahteve + Slab prehod – Strežnik deluje kot prehod in je prejel neveljaven odgovor od gostitelja + Storitev ni na voljo – To se pogosto zgodi, ko je strežnik preobremenjen ali nedosegljiv zaradi vzdrževanja + Časovna omejitev prehoda – Strežnik je deloval kot prehod ali posrednik in je prekoračil časovno omejitev med čakanjem na odgovor + HTTP različica ni podprta – Strežnik ne podpira te različice HTTP, navedene v zahtevi + Različica povzroča pogajanja – Strežnik ima notranjo konfiguracijsko napako + Premalo prostora za shranjevanje – Strežnik nima dovolj prostora za uspešno obdelavo zahteve + Zaznana zanka – Strežnik je zaznal neskončno zanko med obdelavo zahteve + Zahtevana overitev omrežja – Odjemalec mora biti overjen za dostop do omrežja + NIP-96 strežnik + Dodajte poljubno število strežnikov. Kasneje lahko izberete, katerega želite uporabiti pri nalaganju slike + Blossom strežniki + Dodajte poljubno število strežnikov. Kasneje lahko izberete, katerega želite uporabiti pri nalaganju slike + Dodaj NIP-96 strežnik + Dodaj Blossom strežnik + Izbriši vse + Ali ste prepričani, da želite izbrisati vse osnutke? + Sklad: + Torent datoteka + Prenesi + Datoteke ni bilo mogoče odpreti + Ni nameščenih torent aplikacij za odpiranje in prenos datoteke. + Izberite seznam za filtriranje vira + Odjava ob zaklepu naprave diff --git a/amethyst/src/main/res/values-so-rSO/strings.xml b/amethyst/src/main/res/values-so-rSO/strings.xml index 66a570486..3ea04e700 100644 --- a/amethyst/src/main/res/values-so-rSO/strings.xml +++ b/amethyst/src/main/res/values-so-rSO/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-ss-rZA/strings.xml b/amethyst/src/main/res/values-ss-rZA/strings.xml index 66a570486..3ea04e700 100644 --- a/amethyst/src/main/res/values-ss-rZA/strings.xml +++ b/amethyst/src/main/res/values-ss-rZA/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 9688faeb9..52dcd6f0f 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -116,10 +116,13 @@ Profilbild URL Banner URL Websida URL + Pronomen LN Adress LN URL (Utdaterad) Spara i Galleri Bild sparad till galleriet + Nedladdning av video har startat… + Nedladdning av media har startat… Misslyckades att spara bilden Videon sparad i telefonens videogalleri Det gick inte att spara videon @@ -147,6 +150,8 @@ Lightning Adress Kopierar Nsec ID (ditt lösenord) till urklipp för säkerhetskopiering Kopiera hemlig nyckel till urklipp + Visa QR-kod för privat nyckel + Visa QR-kod för krypterad privat nyckel Kopierar den publika nyckeln till urklipp för delning Kopiera Public Key (NPub) till Urklipp Skicka ett direkt meddelande @@ -183,6 +188,7 @@ Laddar kontot "Det gick inte att läsa in svar: " Försök igen + Inga aviseringar ännu. Flöde är tomt. Uppdatera Skapad @@ -202,6 +208,8 @@ översatt från till Visa i %1$s first + Offentlig chatt om %1$s + Offentligt forum om %1$s Översätt alltid till %1$s Översätt aldrig från %1$s Nostr-adress @@ -330,10 +338,13 @@ Vad betyder detta? Bilden är densamma sedan inlägget Bilden har ändrats. Användaren kanske inte har sett förändringen + Lägg till media Lägg till Bild Lägg till Video Lägg till Dokument Lägg till Meddelande + Lägg till en rubrik + Min underbara vän Beskrivning av innehållet En blå båt på en vit sandstrand vid solnedgången Zap Typ @@ -347,15 +358,25 @@ Ingen Zap Inga spår i Nostr, bara i Lightning Fil Server + Välj en server att ladda upp denna fil till LnAdress eller @Användare Mediaservrar Ställ in dina önskade mediauppladdningsservrar. - Du har inga anpassade mediaservrar. Du kan använda Amethysts lista, eller lägga till en under ↓ + Du har inga NIP-96 servrar. Du kan använda Amethyst\'s lista, eller lägga till en nedan ↓ + Du har inga Blossom servrar satta. Du kan använda Amethyst\'s lista, eller lägga till en nedan ↓ Inbyggda mediaservrar Amethysts standardlista. Du kan lägga till dem individuellt eller lägga till listan. Använd standardlistan Lägg till mediaserver Radera mediaserver + Ej påbörjad + Komprimerar + Laddar upp + Behandlar + Laddar ner + Hashning + Klart + Fel Dina Reläer (NIP-95) Filer är värd för dina reläer. Nytt NIP: kontrollera om de stöder Sekretess Alternativ @@ -369,6 +390,7 @@ Nej Följ lista Alla följare + Runt mig Global Tyst listan Standardporten är 9050 @@ -537,11 +559,16 @@ Kontrollerar Nostr-adress Välj/Avmarkera alla Standard + Återställ till standardinställningar Välj ett relä för att fortsätta Vidarebefordra Zaps till: Stödjande klienter kommer att vidarebefordra Zaps till LNAddress eller användarprofilen nedan istället för din egen Exponera plats som Lägger till en Geohash av din plats i inlägget. Allmänheten kommer att veta att du befinner dig inom 5 km från nuvarande plats + Plats-exklusivt inlägg + Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den. + Laddar position + Inga platsbehörigheter Lägger till en varning för känsligt innehåll innan ditt innehåll visas. Detta är idealiskt för NSFW-innehåll (inte säkert för arbete) eller innehåll som vissa personer kan uppleva som stötande eller störande Ny Funktion För att aktivera denna funktion kräver det att Amethyst skickar ett NIP-17 meddelande (GiftWrapped, Förseglade Direkta och Gruppmeddelanden). NIP-17 är nytt och de flesta klienter har ännu inte implementerat det. Se till att mottagaren använder en kompatibel klient. @@ -570,6 +597,8 @@ Kopiera URL till urklipp Kopiera anteckningens ID till urklipp Lägg till media i galleriet + Media tillagd + Media har lagts till i ditt profilgalleri Skapad den Regler Logga in med Amber @@ -704,7 +733,9 @@ Fel vid uppladdning: %1$s Servern gav inte en URL efter uppladdning Kunde inte ladda ner uppladdade medier från servern + Kunde inte kontrollera nedladdad fil efter uppladdning: %1$s Kunde inte förbereda lokal fil att ladda upp: %1$s + Det gick inte att nå %1$s: %2$s Uppladdning misslyckades: %1$s Radering misslyckades: %1$s Media är för stort för NIP-95 @@ -766,6 +797,7 @@ Ställ in dina privata inkorgsreläer Denna inställning låter alla veta vilka reläer som ska användas när de skickar meddelanden till dig. Utan dem kan du missa vissa meddelanden. Bra alternativ är:\n - inbox.nostr.wine (betald)\n - you.nostr1.com (personliga reläer - betald) + Bra alternativ är:\n - auth.nostr1.com (gratis)\n - inbox.nostr.wine (betalad)\n - relay.0xchat.com (gratis) Sätt in mellan 1–3 reläer för att fungera som din privata inkorg. DM Inkorg reläer bör acceptera alla meddelanden från vem som helst, men endast tillåta dig att ladda ner dem. Ställ in nu Sökreläer @@ -819,6 +851,7 @@ Förseglat meddelande på. Klicka för att stänga av förseglat meddelande Skicka Spela upp användarnamn som ljud + Häftstift Skanna QR-kod Navigera till tredjeparts plånboksleverantören Alby Det går inte att svara på ett utkast @@ -861,7 +894,12 @@ Otillräckligt lagringsutrymme - Servern har inte tillräckligt med lagringsutrymme för att bearbeta begäran framgångsrikt Loop upptäckt - Servern upptäcker en oändlig loop medan den bearbetar begäran Nätverksautentisering krävs - Klienten måste autentiseras för att få tillgång till nätverket + NIP-96 Servar + Lägg till så många servrar som du vill. Du kan välja vilken som ska användas senare när du laddar upp din bild + Blossom Servrar + Lägg till så många servrar som du vill. Du kan välja vilken som ska användas senare när du laddar upp din bild Lägg till en NIP-96 Server + Lägg till en Blossom Server Radera alla Är du säker på att du vill ta bort alla utkast? Stack: diff --git a/amethyst/src/main/res/values-th/strings.xml b/amethyst/src/main/res/values-th/strings.xml index 279298ecd..0202f8c75 100644 --- a/amethyst/src/main/res/values-th/strings.xml +++ b/amethyst/src/main/res/values-th/strings.xml @@ -347,7 +347,6 @@ LnAddress หรือ @ชื่อผู้ใช้ Media Servers ตั้งค่า media server ที่คุณต้องการ - คุณยังไม่ได้ตั้งค่า media servers คุณสามารถใช้รายการของ Amethyst หรือตั้งค่าใหม่ด้านล่าง ↓ Built-in Media Servers รายการค่าเริ่มต้นของ Amethyst คุณสามารถเพิ่มทีละรายการหรือเพิ่มทั้งรายการก็ได้ ใช้รายการค่าเริ่มต้น diff --git a/amethyst/src/main/res/values-ur-rIN/strings.xml b/amethyst/src/main/res/values-ur-rIN/strings.xml index 66a570486..3ea04e700 100644 --- a/amethyst/src/main/res/values-ur-rIN/strings.xml +++ b/amethyst/src/main/res/values-ur-rIN/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index f057a58c2..ef68c7d24 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -116,6 +116,7 @@ 头像链接 横幅链接 网站链接 + 称谓代词 闪电地址 LN链接(过期) 保存到相册 @@ -147,6 +148,8 @@ 闪电地址 复制 Nsec ID(您的私人密钥)到剪贴板以备备份 复制私钥到剪贴板 + 显示私钥二维码 + 显示加密私钥二维码 复制公钥到剪贴板以供共享 复制公钥(NPub)到剪贴板 发送直接消息 @@ -183,6 +186,7 @@ 正在载入帐户 "加载回复出错:" 重试 + 暂无通知 信息流为空。 刷新 创建 @@ -202,6 +206,8 @@ 更改为 先显示 %1$s 在前 + 有关 %1$s 的公共聊天 + 有关 %1$s 的公共社区 总是翻译为 %1$s 不再翻译 %1$s Nostr 地址 @@ -332,10 +338,13 @@ 这是什么意思 此内容与发布时相同 此内容已更改。作者可能没有看到或批准更改 + 添加媒体 添加图片 添加视频 添加文件 添加到消息 + 添加标题 + 我最爱的朋友 内容描述 日落时分,白色沙滩上的蓝色小船 打闪类型 @@ -349,15 +358,25 @@ 非打闪 Nostr 上没有痕迹,仅在闪电上 文件服务器 + 选择上传文件时使用的服务器 闪电地址或 @User 媒体服务器 设置首选媒体上传服务器。 - 尚未设置自定义媒体服务器。可以使用默认的内置服务器列表或自行添加 ↓ + 尚未设置 NIP-96 服务器。可以使用默认的内置服务器列表或自行添加 ↓ + 尚未设置 Blossom 服务器。可以使用默认的内置服务器列表或自行添加 ↓ 内置媒体服务器 Amethyst 的默认列表。可单独添加或添加整个列表。 使用默认列表 添加媒体服务器 删除媒体服务器 + 尚未开始 + 正在压缩 + 正在上传 + 正在处理 + 正在下载 + 正在计算 Hash + 完成 + 错误 你的中继器 (NIP-95) 文件由你的中继器托管。新的 NIP:检查它们是否支持 隐私选项 @@ -371,6 +390,7 @@ 关注列表 所有关注 + 周围的人 全球 静音列表 默认端口为 9050 @@ -540,11 +560,16 @@ 正在检查 Nostr 地址 全部选择/取消选择 默认 + 重设为默认值 选择中继器以继续 将打闪转发到: 支持的客户端会将打闪转发到以下的闪电地址或用户个人档案,而不是你的 将位置显示为 将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5公里之内(3英里) + 位置限定帖子 + 只有处于同一地理位置的追随者才能看到贴文。其他追随者无法看到。 + 加载位置中 + 没有位置信息权限 在显示你的内容之前添加敏感的内容警告。针对任何 NSFW 内容或一些人可能觉得有冒犯性或令人不安的内容。 新功能 启用此模式需要 Amethyst 发送一条 NIP-17 消息(包装的、密封的私信和群聊消息)。因为 NIP-17 是新的,大多数客户端尚未执行。请确保接收方正在使用兼容的客户端。 @@ -707,7 +732,9 @@ 上传错误:%1$s 上传后服务器没有提供 URL 无法从服务器下载上传的媒体 + 无法检查经过上传后再下载的文件:%1$s 无法准备要上传的本地文件:%1$s + 无法上传到%1$s:%2$s 上传失败:%1$s 删除失败:%1$s 文件过大,无法使用 NIP-95 @@ -769,6 +796,7 @@ 设置你的私人收件箱中继 该设置可以让所有人知道向您发送消息时应该使用的中继。如果没有的话,您可能会错过一些消息。 示例:\n - inbox.nostr.wine (付费)\n - you.nostr1.com (个人专用中继 - 付费) + 示例:\n - auth.nostr1.com (免费)\n - inbox.nostr.wine (付费)\n - revisy.0xchat.com (免费) 设置 1 ~ 3 个私人收件箱中继。需要确保这些收件箱中继能够接受来自任何人的任何私信消息,但只允许您读取这些消息。 立即设置 搜索中继 @@ -822,6 +850,7 @@ 密封消息开启。点击关闭密封消息 发送 作为音频播放用户名 + 图钉 扫描二维码 导航到第三方钱包提供商 Alby 无法回复笔记草稿 @@ -864,7 +893,12 @@ Insufficient Storage - 服务器没有足够的存储空间用于处理该请求。 Loop Detected - 服务器处理该请求时检测到死循环。 Network Authentication Required - 客户端必须通过认证才能访问该网络。 + NIP-96 服务器 + 添加上传图片时可以选择的服务器。 + Blossom 服务器 + 添加上传图片时可以选择的服务器。 添加 NIP-96 服务器 + 添加 Blossom 服务器 删除所有 确定要删除所有草稿吗? 堆叠: diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 300346e56..6269f3ec5 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -345,7 +345,6 @@ LnAddress 或 @User 媒體服務器 設置首選媒體上傳服務器 - 尚未設置自定義媒體服務器。可以使用默認的內置服務器列表或自行添加 ↓ 內置媒體服務器 Amethyst 的默認列表。可單獨添加或添加整個列表。 使用默認列表 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1b2a583ac..070071ba4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -121,10 +121,13 @@ Avatar URL Banner URL Website URL + Pronouns LN Address LN URL (outdated) Save to Gallery Image saved to the phone\'s photo gallery + Video download has started… + Media download has started… Failed to save the image Video saved to the phone\'s video gallery Failed to save the video @@ -152,6 +155,8 @@ Lightning Address Copies the Nsec ID (your password) to the clipboard for backup Copy Secret Key to the Clipboard + Show private key QR code + Show encrypted private key QR code Copies the public key to the clipboard for sharing Copy Public Key (NPub) to the Clipboard Send a Direct Message @@ -188,6 +193,7 @@ Loading account "Error loading replies: " Try again + No notifications yet. Feed is empty. Refresh created @@ -208,6 +214,8 @@ translated from to Show in %1$s first + Public chat about %1$s + Public community about %1$s Always translate to %1$s Never translate from %1$s Nostr Address @@ -281,7 +289,7 @@ Unfollow Follow Delete from Gallery - Remove this media from your Gallery, you can readd it later + Remove this media from your Gallery. Request Deletion Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored. Block @@ -360,11 +368,15 @@ This content is the same since the post This content has changed. The author might not have seen or approved the change + Add Media Add Image Add Video Add Document Add to Message + Add a Caption + My lovely friend + Description of the contents A blue boat in a white sandy beach at sunset @@ -385,18 +397,31 @@ File Server + Choose a server to upload this file to + LnAddress or @User Media Servers Set your preferred media upload servers. - You have no custom media servers set. You can use Amethyst\'s list, or add one below ↓ + + You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓ + You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓ + Built-in Media Servers Amethyst\'s default list. You can add them individually or add the list. Use Default List Add media server Delete media server + Not Started + Compressing + Uploading + Processing + Downloading + Hashing + Done + Error Your relays (NIP-95) Files are hosted by your relays. New NIP: check if they support @@ -645,6 +670,12 @@ Expose Location as Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location + Location-exclusive Post + Only followers of the location will see it. Your general followers won\'t see it. + + Loading location + No Location Permissions + Adds sensitive content warning before showing your content. This is ideal for any NSFW content or content some people may find offensive or disturbing New Feature @@ -680,6 +711,8 @@ Copy URL to clipboard Copy Note ID to clipboard Add Media to Gallery + Media added + Media added to your Profile Gallery Created at Rules @@ -693,6 +726,7 @@ Unable to send zap Message the User + Message %1$s OK Failed to reach %1$s: %2$s @@ -768,6 +802,7 @@ Could not resolve %1$s. Check if you are connected, if the server is up and if the lightning address %2$s is correct Could not resolve %1$s. Check if you are connected, if the server is up and if the lightning address %2$s is correct.\n\nException was: %3$s Could not fetch invoice from %1$s + Could not fetch invoice from %1$s: %2$s Error Parsing JSON from Lightning Address. Check the user\'s lightning setup Error Parsing JSON from %1$s. Check the user\'s lightning setup @@ -853,7 +888,9 @@ Uploading error: %1$s Server did not provide a URL after uploading Could not download uploaded media from the server + Could not check downloaded file after upload: %1$s Could not prepare local file to upload: %1$s + Failed to upload to %1$s: %2$s Failed to upload: %1$s Failed to delete: %1$s Media is too big for NIP-95 @@ -992,6 +1029,7 @@ Sealed message on. Click to turn off sealed message Send Play username as audio + Pushpin Scan QR code Navigate to the third-party wallet provider Alby It\'s not possible to reply a draft note @@ -1039,7 +1077,14 @@ Loop Detected - The server detects an infinite loop while processing the request Network Authentication Required - The client must be authenticated to access the network + NIP-96 Servers + Add as many servers as you want. You can choose which one to use later when uploading your picture + + Blossom Servers + Add as many servers as you want. You can choose which one to use later when uploading your picture + Add a NIP-96 Server + Add a Blossom Server Delete all Are you sure you want to delete all drafts? " +%1$s" diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index 26cd53136..8783872cb 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -6,7 +6,8 @@ + android:exported="true" + > diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index adc62a017..dbb4de9b4 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -157,7 +157,7 @@ object LanguageTranslatorService { while (matcher.find()) { try { val tag = matcher.group() - val short = "A$counter" + val short = "C$counter" counter++ returningList.put(short, tag) } catch (e: Exception) { @@ -194,7 +194,7 @@ object LanguageTranslatorService { .filter { !it.originalUrl.contains(",") && !it.originalUrl.contains("。") } .associate { counter++ - "A$counter" to it.originalUrl + "B$counter" to it.originalUrl } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index caccf4374..0c2726702 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -28,16 +28,18 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await object PushNotificationUtils { - var hasInit: Boolean = false + var hasInit: List? = null suspend fun init(accounts: List) = with(Dispatchers.IO) { - if (hasInit) { + if (hasInit?.equals(accounts) == true) { return@with } // get user notification token provided by firebase try { RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await()) + + hasInit = accounts.toList() } catch (e: Exception) { if (e is CancellationException) throw e Log.e("Firebase token", "failed to get firebase token", e) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index f9439234a..0e1f626a9 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -255,8 +255,10 @@ private fun TranslationMessage( } }, onClick = { - accountViewModel.account.toggleDontTranslateFrom(source) - langSettingsPopupExpanded = false + scope.launch(Dispatchers.IO) { + accountViewModel.account.toggleDontTranslateFrom(source) + langSettingsPopupExpanded = false + } }, ) HorizontalDivider(thickness = DividerThickness) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt index b80877c3d..f4d6d0658 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever import junit.framework.TestCase.assertEquals import org.junit.Test @@ -136,7 +137,7 @@ class Nip96Test { @Test() fun parseNostrBuild() { - val info = Nip96Retriever().parse("https://nostr.build", json) + val info = ServerInfoRetriever().parse("https://nostr.build", json) assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl) assertEquals("https://media.nostr.build", info.downloadUrl) @@ -165,7 +166,7 @@ class Nip96Test { @Test() fun parseRelativeUrls() { - val info = Nip96Retriever().parse("https://test.com", relativeUrlTest) + val info = ServerInfoRetriever().parse("https://test.com", relativeUrlTest) assertEquals("https://test.com/n96", info.apiUrl) assertEquals("https://test.com/", info.downloadUrl) 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 new file mode 100644 index 000000000..8db0d9fde --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.events.TextNoteEvent +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class NewPostViewModelTest { + @MockK + lateinit var accountViewModel: AccountViewModel + + @MockK(relaxed = true) + lateinit var replyingTo: Note + + private lateinit var newPostViewModelUnderTest: NewPostViewModel + + @Before + fun setup() { + mockkObject(LocalCache) + every { LocalCache.getOrCreateUser(any()) } returns mockk() + newPostViewModelUnderTest = NewPostViewModel() + MockKAnnotations.init(this) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `test load with mentions`() = + runTest { + // Arrange: Setup with non empty mentions + every { accountViewModel.account } returns mockk() + + val textNoteEvent = mockk(relaxed = true) + every { textNoteEvent.mentions() } returns listOf("user1", "user2") + every { replyingTo.event } returns textNoteEvent + + every { accountViewModel.userProfile() } returns mockk(relaxed = true) + + // Act: Call load with mentions + newPostViewModelUnderTest.load(accountViewModel, replyingTo, quote = null, fork = null, version = null, draft = null) + + // Assert + // Two mentions should call LocalCache.getOrCreateUser twice + verify(exactly = 2) { LocalCache.getOrCreateUser(any()) } + } + + @Test + fun `test load with zero mentions`() = + runTest { + // Arrange: Setup Note with zero mentions + every { accountViewModel.account } returns mockk() + + val textNoteEvent = mockk(relaxed = true) + every { textNoteEvent.mentions() } returns emptyList() + every { replyingTo.event } returns textNoteEvent + + every { accountViewModel.userProfile() } returns mockk(relaxed = true) + + // Act: Call load with empty mentions + newPostViewModelUnderTest.load(accountViewModel, replyingTo, quote = null, fork = null, version = null, draft = null) + + // Assert + // With no mentions LocalCache.getOrCreateUser should not be called + verify(exactly = 0) { LocalCache.getOrCreateUser(any()) } + } + + @Test + fun `test load with empty mentions`() = + runTest { + // Arrange: Setup empty mentions + every { accountViewModel.account } returns mockk() + + val textNoteEvent = mockk(relaxed = true) + every { textNoteEvent.mentions() } returns listOf("") + every { replyingTo.event } returns textNoteEvent + + every { accountViewModel.userProfile() } returns mockk(relaxed = true) + + // Act: Call load with empty mentions + newPostViewModelUnderTest.load(accountViewModel, replyingTo, quote = null, fork = null, version = null, draft = null) + + // Assert + // Verify LocalCache.getOrCreateUser(it) is not called with empty hex, it will crash the app + verify(exactly = 0) { LocalCache.getOrCreateUser(any()) } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt index 04bcf10d9..94e894073 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt @@ -24,6 +24,8 @@ import android.content.Context import android.net.Uri import android.os.Looper import com.abedelazizshe.lightcompressorlibrary.VideoCompressor +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils import id.zelory.compressor.Compressor import io.mockk.MockKAnnotations @@ -35,9 +37,11 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify +import junit.framework.TestCase.assertEquals import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before +import org.junit.Ignore import org.junit.Test import java.io.File @@ -73,8 +77,6 @@ class MediaCompressorTest { uri, contentType, applicationContext = mockk(), - onReady = { _, _, _ -> }, - onError = { }, mediaQuality = mediaQuality, ) @@ -96,8 +98,6 @@ class MediaCompressorTest { uri, contentType, applicationContext = mockk(), - onReady = { _, _, _ -> }, - onError = { }, mediaQuality = mediaQuality, ) @@ -107,6 +107,7 @@ class MediaCompressorTest { } @Test + @Ignore("Waits forever for some reason") fun `Video media should invoke video compressor`() = runTest { // setup @@ -121,8 +122,6 @@ class MediaCompressorTest { uri, contentType, applicationContext = mockk(), - onReady = { _, _, _ -> }, - onError = { }, mediaQuality = mediaQuality, ) @@ -147,8 +146,6 @@ class MediaCompressorTest { uri, contentType, applicationContext = mockk(relaxed = true), - onReady = { _, _, _ -> }, - onError = { }, mediaQuality = mediaQuality, ) @@ -162,23 +159,23 @@ class MediaCompressorTest { // setup val mockContext = mockk(relaxed = true) val mockUri = mockk() - val mockOnReady = mockk<(Uri, String?, Long?) -> Unit>(relaxed = true) mockkObject(MediaCompressorFileUtils) every { MediaCompressorFileUtils.from(any(), any()) } returns File("test") coEvery { Compressor.compress(any(), any(), any(), any()) } throws Exception("Compression error") // Execute - MediaCompressor().compress( - uri = mockUri, - contentType = "image/jpeg", - applicationContext = mockContext, - onReady = mockOnReady, - onError = { }, - mediaQuality = CompressorQuality.MEDIUM, - ) + val result = + MediaCompressor().compress( + uri = mockUri, + contentType = "image/jpeg", + applicationContext = mockContext, + mediaQuality = CompressorQuality.MEDIUM, + ) // Verify: onReady should be called with original uri, content type, and null size - verify { mockOnReady.invoke(mockUri, "image/jpeg", null) } + assertEquals(mockUri, result.uri) + assertEquals("image/jpeg", result.contentType) + assertEquals(null, result.size) } } diff --git a/ammolite/build.gradle b/ammolite/build.gradle index c6a7101e7..ecd515719 100644 --- a/ammolite/build.gradle +++ b/ammolite/build.gradle @@ -57,7 +57,6 @@ dependencies { implementation libs.androidx.runtime.runtime implementation project(path: ':quartz') - implementation libs.okhttp testImplementation libs.junit diff --git a/ammolite/consumer-rules.pro b/ammolite/consumer-rules.pro index 2338beda6..a98136082 100644 --- a/ammolite/consumer-rules.pro +++ b/ammolite/consumer-rules.pro @@ -42,4 +42,5 @@ -keep enum ** { *; } -keep class com.vitorpamplona.ammolite.service.** { *; } --keep class com.vitorpamplona.ammolite.relays.** { *; } \ No newline at end of file +-keep class com.vitorpamplona.ammolite.relays.** { *; } +-keep class com.vitorpamplona.ammolite.sockets.** { *; } \ No newline at end of file diff --git a/ammolite/proguard-rules.pro b/ammolite/proguard-rules.pro index 2338beda6..a98136082 100644 --- a/ammolite/proguard-rules.pro +++ b/ammolite/proguard-rules.pro @@ -42,4 +42,5 @@ -keep enum ** { *; } -keep class com.vitorpamplona.ammolite.service.** { *; } --keep class com.vitorpamplona.ammolite.relays.** { *; } \ No newline at end of file +-keep class com.vitorpamplona.ammolite.relays.** { *; } +-keep class com.vitorpamplona.ammolite.sockets.** { *; } \ No newline at end of file diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Client.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt similarity index 85% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Client.kt rename to ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt index 47d05e51a..9d4b9009b 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Client.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.ammolite.relays import android.util.Log import com.vitorpamplona.ammolite.service.checkNotInMainThread +import com.vitorpamplona.ammolite.sockets.WebsocketBuilder import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import kotlinx.coroutines.DelicateCoroutinesApi @@ -37,10 +38,16 @@ import java.util.concurrent.TimeUnit * The Nostr Client manages multiple personae the user may switch between. Events are received and * published through multiple relays. Events are stored with their respective persona. */ -object Client : RelayPool.Listener { +class NostrClient( + private val websocketBuilder: WebsocketBuilder, +) : RelayPool.Listener { + private val relayPool: RelayPool = RelayPool() + private val subscriptions: MutableSubscriptionManager = MutableSubscriptionManager() + private var listeners = setOf() private var relays = emptyArray() - private var subscriptions = mapOf>() + + fun buildRelay(it: RelaySetupInfoToConnect): Relay = Relay(it.url, it.read, it.write, it.forceProxy, it.feedTypes, websocketBuilder, subscriptions) @Synchronized fun reconnect( @@ -52,33 +59,33 @@ object Client : RelayPool.Listener { if (onlyIfChanged) { if (!isSameRelaySetConfig(relays)) { - if (Client.relays.isNotEmpty()) { - RelayPool.disconnect() - RelayPool.unregister(this) - RelayPool.unloadRelays() + if (this.relays.isNotEmpty()) { + relayPool.disconnect() + relayPool.unregister(this) + relayPool.unloadRelays() } if (relays != null) { - val newRelays = relays.map { Relay(it.url, it.read, it.write, it.forceProxy, it.feedTypes) } - RelayPool.register(this) - RelayPool.loadRelays(newRelays) - RelayPool.requestAndWatch() - Client.relays = newRelays.toTypedArray() + val newRelays = relays.map(::buildRelay) + relayPool.register(this) + relayPool.loadRelays(newRelays) + relayPool.requestAndWatch() + this.relays = newRelays.toTypedArray() } } } else { - if (Client.relays.isNotEmpty()) { - RelayPool.disconnect() - RelayPool.unregister(this) - RelayPool.unloadRelays() + if (this.relays.isNotEmpty()) { + relayPool.disconnect() + relayPool.unregister(this) + relayPool.unloadRelays() } if (relays != null) { - val newRelays = relays.map { Relay(it.url, it.read, it.write, it.forceProxy, it.feedTypes) } - RelayPool.register(this) - RelayPool.loadRelays(newRelays) - RelayPool.requestAndWatch() - Client.relays = newRelays.toTypedArray() + val newRelays = relays.map(::buildRelay) + relayPool.register(this) + relayPool.loadRelays(newRelays) + relayPool.requestAndWatch() + this.relays = newRelays.toTypedArray() } } } @@ -101,8 +108,8 @@ object Client : RelayPool.Listener { ) { checkNotInMainThread() - subscriptions = subscriptions + Pair(subscriptionId, filters) - RelayPool.sendFilter(subscriptionId, filters) + subscriptions.add(subscriptionId, filters) + relayPool.sendFilter(subscriptionId, filters) } fun sendFilterAndStopOnFirstResponse( @@ -129,8 +136,8 @@ object Client : RelayPool.Listener { }, ) - subscriptions = subscriptions + Pair(subscriptionId, filters) - RelayPool.sendFilter(subscriptionId, filters) + subscriptions.add(subscriptionId, filters) + relayPool.sendFilter(subscriptionId, filters) } @OptIn(DelicateCoroutinesApi::class) @@ -146,7 +153,7 @@ object Client : RelayPool.Listener { ): Boolean { checkNotInMainThread() - val size = if (relay != null) 1 else relayList?.size ?: RelayPool.availableRelays() + val size = if (relay != null) 1 else relayList?.size ?: relayPool.availableRelays() val latch = CountDownLatch(size) val relayErrors = mutableMapOf() var result = false @@ -227,8 +234,8 @@ object Client : RelayPool.Listener { ) { checkNotInMainThread() - subscriptions = subscriptions + Pair(subscriptionId, filters) - RelayPool.connectAndSendFiltersIfDisconnected() + subscriptions.add(subscriptionId, filters) + relayPool.connectAndSendFiltersIfDisconnected() } fun sendIfExists( @@ -237,7 +244,7 @@ object Client : RelayPool.Listener { ) { checkNotInMainThread() - RelayPool.getRelays(connectedRelay.url).forEach { + relayPool.getRelays(connectedRelay.url).forEach { it.send(signedEvent) } } @@ -249,14 +256,14 @@ object Client : RelayPool.Listener { ) { checkNotInMainThread() - RelayPool.getOrCreateRelay(relayTemplate, onDone) { + relayPool.runCreatingIfNeeded(buildRelay(relayTemplate), onDone = onDone) { it.send(signedEvent) } } fun send(signedEvent: EventInterface) { checkNotInMainThread() - RelayPool.send(signedEvent) + relayPool.send(signedEvent) } fun send( @@ -265,7 +272,7 @@ object Client : RelayPool.Listener { ) { checkNotInMainThread() - RelayPool.sendToSelectedRelays(relayList, signedEvent) + relayPool.sendToSelectedRelays(relayList, signedEvent) } fun sendPrivately( @@ -275,18 +282,18 @@ object Client : RelayPool.Listener { checkNotInMainThread() relayList.forEach { relayTemplate -> - RelayPool.getOrCreateRelay(relayTemplate, { }) { + relayPool.runCreatingIfNeeded(buildRelay(relayTemplate)) { it.sendOverride(signedEvent) } } } fun close(subscriptionId: String) { - RelayPool.close(subscriptionId) - subscriptions = subscriptions.minus(subscriptionId) + relayPool.close(subscriptionId) + subscriptions.remove(subscriptionId) } - fun isActive(subscriptionId: String): Boolean = subscriptions.contains(subscriptionId) + fun isActive(subscriptionId: String): Boolean = subscriptions.isActive(subscriptionId) @OptIn(DelicateCoroutinesApi::class) override fun onEvent( @@ -392,9 +399,13 @@ object Client : RelayPool.Listener { listeners = listeners.minus(listener) } - fun allSubscriptions(): Map> = subscriptions + fun allSubscriptions(): Map> = subscriptions.allSubscriptions() - fun getSubscriptionFilters(subId: String): List = subscriptions[subId] ?: emptyList() + fun getSubscriptionFilters(subId: String): List = subscriptions.getSubscriptionFilters(subId) + + fun connectedRelays() = relayPool.connectedRelays() + + fun relayStatusFlow() = relayPool.statusFlow interface Listener { /** A new message was received */ 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 44e8a2df0..b70ce6d92 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt @@ -35,6 +35,7 @@ import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean abstract class NostrDataSource( + val client: NostrClient, val debugName: String, ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -67,7 +68,7 @@ abstract class NostrDataSource( ): Int = 31 * str1.hashCode() + str2.hashCode() private val clientListener = - object : Client.Listener { + object : NostrClient.Listener { override fun onEvent( event: Event, subscriptionId: String, @@ -139,14 +140,14 @@ abstract class NostrDataSource( init { Log.d("DataSource", "${this.javaClass.simpleName} Subscribe") - Client.subscribe(clientListener) + client.subscribe(clientListener) } fun destroy() { // makes sure to run Log.d("DataSource", "${this.javaClass.simpleName} Unsubscribe") stop() - Client.unsubscribe(clientListener) + client.unsubscribe(clientListener) scope.cancel() bundler.cancel() } @@ -170,7 +171,7 @@ abstract class NostrDataSource( GlobalScope.launch(Dispatchers.IO) { subscriptions.values.forEach { subscription -> - Client.close(subscription.id) + client.close(subscription.id) subscription.typedFilters = null } } @@ -181,7 +182,7 @@ abstract class NostrDataSource( Log.d("DataSource", "${this.javaClass.simpleName} Stop") subscriptions.values.forEach { subscription -> - Client.close(subscription.id) + client.close(subscription.id) subscription.typedFilters = null } } @@ -193,7 +194,7 @@ abstract class NostrDataSource( } fun dismissChannel(subscription: Subscription) { - Client.close(subscription.id) + client.close(subscription.id) subscriptions = subscriptions.minus(subscription.id) } @@ -231,29 +232,29 @@ abstract class NostrDataSource( subscriptions.values.forEach { updatedSubscription -> val updatedSubscriptionNewFilters = updatedSubscription.typedFilters - val isActive = Client.isActive(updatedSubscription.id) + val isActive = client.isActive(updatedSubscription.id) if (!isActive && updatedSubscriptionNewFilters != null) { // Filter was removed from the active list if (active) { - Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) } } else { if (currentFilters.containsKey(updatedSubscription.id)) { if (updatedSubscriptionNewFilters == null) { // was active and is not active anymore, just close. - Client.close(updatedSubscription.id) + client.close(updatedSubscription.id) } else { // was active and is still active, check if it has changed. if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) { - Client.close(updatedSubscription.id) + client.close(updatedSubscription.id) if (active) { - Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) } } else { // hasn't changed, does nothing. if (active) { - Client.sendFilterOnlyIfDisconnected( + client.sendFilterOnlyIfDisconnected( updatedSubscription.id, updatedSubscriptionNewFilters, ) @@ -269,9 +270,9 @@ abstract class NostrDataSource( if (active) { Log.d( this@NostrDataSource.javaClass.simpleName, - "Update Filter 3 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", + "Update Filter 3 ${updatedSubscription.id} ${client.isSubscribed(clientListener)}", ) - Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) } } } 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 aa73eac31..55355e78c 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.ammolite.relays import android.util.Log import com.vitorpamplona.ammolite.BuildConfig -import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.ammolite.service.checkNotInMainThread +import com.vitorpamplona.ammolite.sockets.WebSocket +import com.vitorpamplona.ammolite.sockets.WebSocketListener +import com.vitorpamplona.ammolite.sockets.WebsocketBuilder import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface @@ -31,10 +33,6 @@ import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import kotlinx.coroutines.CancellationException -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener import java.util.concurrent.atomic.AtomicBoolean enum class FeedType { @@ -46,6 +44,9 @@ enum class FeedType { WALLET_CONNECT, } +val ALL_FEED_TYPES = + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH) + val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) @@ -58,6 +59,8 @@ class Relay( val write: Boolean = true, val forceProxy: Boolean = false, val activeTypes: Set, + val socketBuilder: WebsocketBuilder, + val subs: SubscriptionManager, ) { companion object { // waits 3 minutes to reconnect once things fail @@ -129,13 +132,8 @@ class Relay( lastConnectTentative = TimeUtils.now() - val request = - Request - .Builder() - .url(url.trim()) - .build() - - socket = HttpClientManager.getHttpClient(forceProxy).newWebSocket(request, RelayListener(onConnected)) + socket = socketBuilder.build(url, false, RelayListener(onConnected)) + socket?.connect() } catch (e: Exception) { if (e is CancellationException) throw e @@ -150,19 +148,15 @@ class Relay( inner class RelayListener( val onConnected: (Relay) -> Unit, - ) : WebSocketListener() { + ) : WebSocketListener { override fun onOpen( - webSocket: WebSocket, - response: Response, + pingMillis: Long, + compression: Boolean, ) { checkNotInMainThread() Log.d("Relay", "Connect onOpen $url $socket") - markConnectionAsReady( - pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis, - usingCompression = - response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false, - ) + markConnectionAsReady(pingMillis, compression) // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") onConnected(this@Relay) @@ -170,10 +164,7 @@ class Relay( listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) } } - override fun onMessage( - webSocket: WebSocket, - text: String, - ) { + override fun onMessage(text: String) { checkNotInMainThread() RelayStats.addBytesReceived(url, text.bytesUsedInMemory()) @@ -190,7 +181,6 @@ class Relay( } override fun onClosing( - webSocket: WebSocket, code: Int, reason: String, ) { @@ -208,7 +198,6 @@ class Relay( } override fun onClosed( - webSocket: WebSocket, code: Int, reason: String, ) { @@ -222,9 +211,8 @@ class Relay( } override fun onFailure( - webSocket: WebSocket, t: Throwable, - response: Response?, + responseMessage: String?, ) { checkNotInMainThread() @@ -232,19 +220,19 @@ class Relay( // checks if this is an actual failure. Closing the socket generates an onFailure as well. if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) { - RelayStats.newError(url, response?.message ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}") + RelayStats.newError(url, responseMessage ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}") } // Failures disconnect the relay. markConnectionAsClosed() - Log.w("Relay", "Relay onFailure $url, ${response?.message} $response ${t.message} $socket") + Log.w("Relay", "Relay onFailure $url, $responseMessage $responseMessage ${t.message} $socket") t.printStackTrace() listeners.forEach { it.onError( this@Relay, "", - Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t), + Error("WebSocket Failure. Response: $responseMessage. Exception: ${t.message}", t), ) } } @@ -458,7 +446,7 @@ class Relay( fun renewFilters() { // Force update all filters after AUTH. - Client.allSubscriptions().forEach { + subs.allSubscriptions().forEach { sendFilter(requestId = it.key, it.value) } } 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 4d744b195..988039aa7 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.ammolite.service.checkNotInMainThread import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.BufferOverflow @@ -37,7 +36,7 @@ import kotlinx.coroutines.launch /** * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. */ -object RelayPool : Relay.Listener { +class RelayPool : Relay.Listener { private var relays = listOf() private var listeners = setOf() @@ -57,57 +56,34 @@ object RelayPool : Relay.Listener { fun getAll() = relays - fun getOrCreateRelay( - relayTemplate: RelaySetupInfoToConnect, + fun runCreatingIfNeeded( + relay: Relay, + timeout: Long = 60000, onDone: (() -> Unit)? = null, whenConnected: (Relay) -> Unit, ) { synchronized(this) { - val matching = getRelays(relayTemplate.url) + val matching = getRelays(relay.url) if (matching.isNotEmpty()) { matching.forEach { whenConnected(it) } } else { - /** temporary connection */ - newSporadicRelay( - relayTemplate.url, - relayTemplate.read, - relayTemplate.write, - relayTemplate.forceProxy, - relayTemplate.feedTypes, - onConnected = whenConnected, - onDone = onDone, - ) - } - } - } + addRelay(relay) - @OptIn(DelicateCoroutinesApi::class) - fun newSporadicRelay( - url: String, - read: Boolean, - write: Boolean, - forceProxy: Boolean, - feedTypes: Set?, - onConnected: (Relay) -> Unit, - onDone: (() -> Unit)?, - timeout: Long = 60000, - ) { - val relay = Relay(url, read, write, forceProxy, feedTypes ?: emptySet()) - addRelay(relay) + relay.connectAndRun { + relay.renewFilters() + relay.sendOutbox() - relay.connectAndRun { - relay.renewFilters() - relay.sendOutbox() + whenConnected(relay) - onConnected(relay) + GlobalScope.launch(Dispatchers.IO) { + delay(timeout) // waits for a reply + relay.disconnect() + removeRelay(relay) - GlobalScope.launch(Dispatchers.IO) { - delay(timeout) // waits for a reply - relay.disconnect() - removeRelay(relay) - - if (onDone != null) { - onDone() + if (onDone != null) { + onDone() + } + } } } } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionManager.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionManager.kt new file mode 100644 index 000000000..a132d299f --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionManager.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.ammolite.relays + +class MutableSubscriptionManager : SubscriptionManager { + private var subscriptions = mapOf>() + + fun add( + subscriptionId: String, + filters: List = listOf(), + ) { + subscriptions = subscriptions + Pair(subscriptionId, filters) + } + + fun remove(subscriptionId: String) { + subscriptions = subscriptions.minus(subscriptionId) + } + + override fun isActive(subscriptionId: String): Boolean = subscriptions.contains(subscriptionId) + + override fun allSubscriptions(): Map> = subscriptions + + override fun getSubscriptionFilters(subId: String): List = subscriptions[subId] ?: emptyList() +} + +interface SubscriptionManager { + fun isActive(subscriptionId: String): Boolean + + fun allSubscriptions(): Map> + + fun getSubscriptionFilters(subId: String): List +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocket.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocket.kt new file mode 100644 index 000000000..47a6430a4 --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocket.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.ammolite.sockets + +interface WebSocket { + fun connect() + + fun cancel() + + fun send(msg: String): Boolean +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocketListener.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocketListener.kt new file mode 100644 index 000000000..84997bfae --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocketListener.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.ammolite.sockets + +interface WebSocketListener { + fun onOpen( + pingMillis: Long, + compression: Boolean, + ) + + fun onMessage(text: String) + + fun onClosing( + code: Int, + reason: String, + ) + + fun onClosed( + code: Int, + reason: String, + ) + + fun onFailure( + t: Throwable, + response: String?, + ) +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebsocketBuilder.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebsocketBuilder.kt new file mode 100644 index 000000000..c0af7ea00 --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebsocketBuilder.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.ammolite.sockets + +interface WebsocketBuilder { + fun build( + url: String, + forceProxy: Boolean, + out: WebSocketListener, + ): WebSocket +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt index a0dbf7394..8210d8ea1 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder +import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith 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 190eb3ff9..d0af6b68c 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt @@ -46,7 +46,7 @@ class RichTextParserBenchmark { fun parseApkUrl() { benchmarkRule.measureRepeated { assertNull( - RichTextParser().parseMediaUrl( + RichTextParser().createMediaContent( "https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-googleplay-universal-v0.83.10.apk", EmptyTagList, null, 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 b18c944c2..68266efd8 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt @@ -24,7 +24,6 @@ import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.encoders.HexValidator -import junit.framework.TestCase.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -37,58 +36,59 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class HexBenchmark { - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val r = BenchmarkRule() - val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val hex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val bytes = + fr.acinq.secp256k1.Hex + .decode(hex) @Test fun hexDecodeOurs() { - benchmarkRule.measureRepeated { + r.measureRepeated { com.vitorpamplona.quartz.encoders.Hex - .decode(testHex) + .decode(hex) } } @Test fun hexEncodeOurs() { - val bytes = + r.measureRepeated { com.vitorpamplona.quartz.encoders.Hex - .decode(testHex) - - benchmarkRule.measureRepeated { - assertEquals( - testHex, - com.vitorpamplona.quartz.encoders.Hex - .encode(bytes), - ) + .encode(bytes) } } @Test fun hexDecodeBaseSecp() { - benchmarkRule.measureRepeated { + r.measureRepeated { fr.acinq.secp256k1.Hex - .decode(testHex) + .decode(hex) } } @Test fun hexEncodeBaseSecp() { - val bytes = + r.measureRepeated { fr.acinq.secp256k1.Hex - .decode(testHex) - - benchmarkRule.measureRepeated { - assertEquals( - testHex, - fr.acinq.secp256k1.Hex - .encode(bytes), - ) + .encode(bytes) } } + @OptIn(ExperimentalStdlibApi::class) + @Test + fun hexDecodeKotlin() { + r.measureRepeated { hex.hexToByteArray(HexFormat.Default) } + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun hexEncodeKotlin() { + r.measureRepeated { bytes.toHexString(HexFormat.Default) } + } + @Test fun isHex() { - benchmarkRule.measureRepeated { HexValidator.isHex(testHex) } + r.measureRepeated { HexValidator.isHex(hex) } } } diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt index ba2af780f..cc4b1c848 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt @@ -20,18 +20,26 @@ */ package com.vitorpamplona.amethyst.commons.preview +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder +import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoderOld +import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash +import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import org.junit.Assert import org.junit.Test +import org.junit.runner.RunWith import kotlin.math.roundToInt +@RunWith(AndroidJUnit4::class) class BlurhashTest { val warmHex = "[45#Y7_2^-xt%OSb%4S0-qt0xbotaRInV|M{RlD~M{M_IVIUNHM{M{M{M{RjNGRkoyj]o[t8tPt8" val testHex = "|NHL-]~pabocs+jDM{j?of4T9ZR+WBWZbdR-WCog04ITn\$t6t6t6t6oJoLZ}?bIUWBs:M{WCogRjs:s+o#R+WBoft7axWBx]IV%LogM{t5xaWBay%KRjxus.WCNGWWt7j[j]s+R-S5ofjYV@j[ofD%t8RPoJt7t7R*WCof" @Test fun testAspectRatioWarm() { - Assert.assertEquals(0.44444445f, BlurHashDecoderOld.aspectRatio(warmHex)!!, 0.001f) Assert.assertEquals(0.44444445f, BlurHashDecoder.aspectRatio(warmHex)!!, 0.001f) } @@ -45,9 +53,18 @@ class BlurhashTest { assertTrue(bmp1!!.sameAs(bmp2!!)) } + @Test + fun testDecoderWarm25Pixels() { + val aspectRatio = BlurHashDecoder.aspectRatio(warmHex) ?: 1.0f + + val bmp1 = BlurHashDecoderOld.decode(warmHex, 25, (25 * (1 / aspectRatio)).roundToInt()) + val bmp2 = BlurHashDecoder.decodeKeepAspectRatio(warmHex, 25) + + assertTrue(bmp1!!.sameAs(bmp2!!)) + } + @Test fun testAspectRatioTest() { - Assert.assertEquals(1.0f, BlurHashDecoderOld.aspectRatio(testHex)!!) Assert.assertEquals(1.0f, BlurHashDecoder.aspectRatio(testHex)!!) } @@ -60,4 +77,30 @@ class BlurhashTest { assertTrue(bmp1!!.sameAs(bmp2!!)) } + + @Test + fun testBlack() { + assertEquals("U00000fQfQfQfQfQfQfQfQfQfQfQfQfQfQfQ", load("/black.png").toBlurhash()) + } + + @Test + fun test1x1() { + assertEquals("U~TSUA~q~q~q~q~q~q~q~q~q~q~q~q~q~q~q", load("/1x1.png").toBlurhash()) + } + + @Test + fun testWhite() { + assertEquals("U2TSUA~qfQ~q~qj[fQj[fQfQfQfQ~qj[fQj[", load("/white.png").toBlurhash()) + } + + @Test + fun testLorikeet() { + println("${load("/lorikeet.jpg").toBlurhash()}") + assertEquals("rFDcT@_LNs#p%Mt*nNM}E2VrIVX6VuV@WUo{xtjv9]RRw[OXS}rrWFX9w{OZxaxWNHX4n\$M}NGaK%0RkM}w{xto|jFs,Sh-Tj]bcwJnjXSxZs.NI", load("/lorikeet.jpg").toBlurhash()) + } + + private fun load(filename: String): Bitmap = + javaClass.getResourceAsStream(filename).use { inputStream -> + BitmapFactory.decodeStream(inputStream) + } } diff --git a/commons/src/androidTest/resources/1x1.png b/commons/src/androidTest/resources/1x1.png new file mode 100644 index 000000000..2ce4ee021 Binary files /dev/null and b/commons/src/androidTest/resources/1x1.png differ diff --git a/commons/src/androidTest/resources/black.png b/commons/src/androidTest/resources/black.png new file mode 100644 index 000000000..9862a47d2 Binary files /dev/null and b/commons/src/androidTest/resources/black.png differ diff --git a/commons/src/androidTest/resources/lorikeet.jpg b/commons/src/androidTest/resources/lorikeet.jpg new file mode 100644 index 000000000..faef25103 Binary files /dev/null and b/commons/src/androidTest/resources/lorikeet.jpg differ diff --git a/commons/src/androidTest/resources/white.png b/commons/src/androidTest/resources/white.png new file mode 100644 index 000000000..922cdcefd Binary files /dev/null and b/commons/src/androidTest/resources/white.png differ diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt new file mode 100644 index 000000000..c62598555 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.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.amethyst.commons.blurhash + +import kotlin.math.ceil +import kotlin.math.log + +object Base83 { + fun encode(value: Long): String = + if (value > 82) { + encode(value, ceil(log((value + 1).toDouble(), 83.0)).toInt()) + } else { + encode(value, 1) + } + + fun encode( + value: Long, + length: Int, + ): String { + val buffer = CharArray(length) + encode(value, length, buffer, 0) + return String(buffer) + } + + fun encode( + value: Long, + length: Int, + buffer: CharArray, + offset: Int, + ) { + var exp = 1L + for (i in 1..length) { + val digit = (value / exp % 83).toInt() + buffer[offset + length - i] = ALPHABET[digit] + exp *= 83 + } + } + + fun decodeAt( + str: String, + at: Int = 0, + ): Int = charMap[str[at].code] + + fun decodeFixed2( + str: String, + from: Int = 0, + ): Int = charMap[str[from].code] * 83 + charMap[str[from + 1].code] + + fun decode( + str: String, + from: Int = 0, + to: Int = str.length, + ): Int { + var result = 0 + for (i in from until to) { + result = result * 83 + charMap[str[i].code] + } + return result + } + + val ALPHABET: CharArray = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".toCharArray() + + private val charMap = + ALPHABET + .mapIndexed { i, c -> c.code to i } + .toMap() + .let { charMap -> + Array(255) { + charMap[it] ?: 0 + } + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt new file mode 100644 index 000000000..0457e7091 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.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.amethyst.commons.blurhash + +import android.graphics.Bitmap +import kotlin.math.roundToInt + +fun Bitmap.toBlurhash(): String { + val aspectRatio = this.width.toFloat() / this.height.toFloat() + + if (this.width > 100 && this.height > 100) { + return Bitmap.createScaledBitmap(this, 100, (100 / aspectRatio).toInt(), false).toBlurhash() + } + + val intArray = IntArray(width * height) + this.getPixels(intArray, 0, width, 0, 0, width, height) + + val numX = + if (aspectRatio > 1) { + 9 + } else if (aspectRatio < 1) { + (9 * aspectRatio).roundToInt() + } else { + 4 + } + + val numY = + if (aspectRatio > 1) { + (9 * (1 / aspectRatio)).roundToInt() + } else if (aspectRatio < 1) { + 9 + } else { + 4 + } + + return BlurHashEncoder().encode( + intArray, + width, + height, + numX, + numY, + ) +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt new file mode 100644 index 000000000..0548d763f --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt @@ -0,0 +1,164 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.blurhash + +import android.graphics.Bitmap +import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb +import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.srgbToLinear +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.withSign + +object BlurHashDecoder { + /** Returns width/height */ + fun aspectRatio(blurHash: String?): Float? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = Base83.decodeAt(blurHash, 0) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + + return numCompX.toFloat() / numCompY.toFloat() + } + + fun computeColors( + numCompX: Int, + numCompY: Int, + blurHash: String, + ): Array { + val maxAc = (Base83.decodeAt(blurHash, 1) + 1) / 166f + return Array(numCompX * numCompY) { i -> + if (i == 0) { + decodeDc(Base83.decode(blurHash, 2, 6)) + } else { + decodeAc(Base83.decodeFixed2(blurHash, 4 + i * 2), maxAc) + } + } + } + + fun computeNumComponets(blurHash: String): Pair { + val numCompEnc = Base83.decodeAt(blurHash, 0) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + return Pair(numCompX, numCompY) + } + + /** + * Decode a blur hash into a new bitmap. + * + * @param useCache use in memory cache for the calculated math, reused by images with same size. + * if the cache does not exist yet it will be created and populated with new calculations. By + * default it is true. + */ + fun decodeKeepAspectRatio( + blurHash: String?, + width: Int, + useCache: Boolean = true, + ): Bitmap? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val (numCompX, numCompY) = computeNumComponets(blurHash) + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val height = (width * (1 / (numCompX.toFloat() / numCompY.toFloat()))).roundToInt() + val colors = computeColors(numCompX, numCompY, blurHash) + val imageArray = composeImageArray(width, height, numCompX, numCompY, colors, useCache) + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun decodeAc( + value: Int, + maxAc: Float, + ): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc, + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeImageArray( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + useCache: Boolean, + ): IntArray { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !CosineCache.hasX(width * numCompX) + val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !CosineCache.hasY(height * numCompY) + val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, numCompY) + + var r = 0.0f + var g = 0.0f + var b = 0.0f + + for (y in 0 until height) { + for (x in 0 until width) { + r = 0.0f + g = 0.0f + b = 0.0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosY = cosinesY[j + numCompY * y] + val cosX = cosinesX[i + numCompX * x] + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + + imageArray[x + width * y] = rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + + return imageArray + } + + fun rgb( + red: Int, + green: Int, + blue: Int, + ): Int = -0x1000000 or (red shl 16) or (green shl 8) or blue +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt rename to commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt index ee2429ce2..81fda02c1 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.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.commons.preview +package com.vitorpamplona.amethyst.commons.blurhash import android.graphics.Bitmap import android.graphics.Color diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt new file mode 100644 index 000000000..c48b5e0a5 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.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.amethyst.commons.blurhash + +import com.vitorpamplona.amethyst.commons.blurhash.Base83.encode +import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.withSign + +class BlurHashEncoder { + fun signPow( + value: Double, + exp: Double, + ): Double = abs(value).pow(exp).withSign(value) + + private fun encodeAC( + value: DoubleArray, + maximumValue: Double, + ): Long { + val quantR = floor(max(0.0, min(18.0, floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5)))) + val quantG = floor(max(0.0, min(18.0, floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5)))) + val quantB = floor(max(0.0, min(18.0, floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5)))) + return Math.round(quantR * 19 * 19 + quantG * 19 + quantB) + } + + private fun encodeDC(value: DoubleArray): Long { + val r = linearToSrgb(value[0]).toLong() + val g = linearToSrgb(value[1]).toLong() + val b = linearToSrgb(value[2]).toLong() + return (r shl 16) + (g shl 8) + b + } + + fun max( + values: Array, + from: Int, + endExclusive: Int, + ): Double { + var result = Double.NEGATIVE_INFINITY + for (i in from until endExclusive) { + for (j in values[i].indices) { + val value = values[i][j] + if (value > result) { + result = value + } + } + } + return result + } + + fun encode( + pixels: IntArray, + width: Int, + height: Int, + componentX: Int, + componentY: Int, + useCache: Boolean = true, + ): String { + require(!(componentX < 1 || componentX > 9 || componentY < 1 || componentY > 9)) { "Blur hash must have between 1 and 9 components" } + require(width * height == pixels.size) { "Width and height must match the pixels array" } + + val factors = Array(componentX * componentY) { DoubleArray(3) } + + val calculateCosX = !useCache || !CosineCache.hasX(width * componentX) + val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, componentX) + val calculateCosY = !useCache || !CosineCache.hasY(height * componentY) + val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, componentY) + + val scale = 1.0 / (width * height) + + var r = 0.0 + var g = 0.0 + var b = 0.0 + + for (j in 0 until componentY) { + for (i in 0 until componentX) { + val normalisation = (if (i == 0 && j == 0) 1 else 2).toDouble() + + r = 0.0 + g = 0.0 + b = 0.0 + for (x in 0 until width) { + for (y in 0 until height) { + val basis = normalisation * cosinesY[j + componentY * y] * cosinesX[i + componentX * x] + val pixel = pixels[y * width + x] + r += basis * SRGB.srgbToLinear((pixel shr 16) and 0xff) + g += basis * SRGB.srgbToLinear((pixel shr 8) and 0xff) + b += basis * SRGB.srgbToLinear(pixel and 0xff) + } + } + + val colors = factors[j * componentX + i] + colors[0] = r * scale + colors[1] = g * scale + colors[2] = b * scale + } + } + + val hash = CharArray(1 + 1 + 4 + 2 * (factors.size - 1)) // size flag + max AC + DC + 2 * AC components + val sizeFlag = (componentX - 1 + (componentY - 1) * 9).toLong() + encode(sizeFlag, 1, hash, 0) + + val maximumValue: Double + if (factors.size > 1) { + val actualMaximumValue = max(factors, 1, factors.size) + val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5)))) + maximumValue = (quantisedMaximumValue + 1) / 166 + encode(Math.round(quantisedMaximumValue), 1, hash, 1) + } else { + maximumValue = 1.0 + encode(0, 1, hash, 1) + } + + val dc = factors[0] + encode(encodeDC(dc), 4, hash, 2) + + for (i in 1 until factors.size) { + encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1)) + } + return String(hash) + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt new file mode 100644 index 000000000..5f18cbaf5 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.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.amethyst.commons.blurhash + +import androidx.collection.LruCache +import kotlin.math.cos + +object CosineCache { + // cache Math.cos() calculations to improve performance. + // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * + // 2 * nBitmaps + // the cache is enabled by default, it is recommended to disable it only when just a few images + // are displayed + private val cacheCosinesX = LruCache(20) + private val cacheCosinesY = LruCache(20) + + /** + * Clear calculations stored in memory cache. The cache is not big, but will increase when many + * image sizes are used, if the app needs memory it is recommended to clear it. + */ + fun clearCache() { + cacheCosinesX.evictAll() + cacheCosinesY.evictAll() + } + + fun hasX(idx: Int) = cacheCosinesX.get(idx) != null + + fun hasY(idx: Int) = cacheCosinesY.get(idx) != null + + fun getArrayForCosinesY( + calculate: Boolean, + height: Int, + numCompY: Int, + ) = when { + calculate -> { + DoubleArray(height * numCompY) { + val y = it / numCompY + val j = it % numCompY + cos(Math.PI * y * j / height) + }.also { + cacheCosinesY.put(height * numCompY, it) + } + } + else -> { + cacheCosinesY[height * numCompY]!! + } + } + + fun getArrayForCosinesX( + calculate: Boolean, + width: Int, + numCompX: Int, + ) = when { + calculate -> { + DoubleArray(width * numCompX) { + val x = it / numCompX + val i = it % numCompX + cos(Math.PI * x * i / width) + }.also { cacheCosinesX.put(width * numCompX, it) } + } + else -> cacheCosinesX[width * numCompX]!! + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt new file mode 100644 index 000000000..e2cb5df15 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.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.amethyst.commons.blurhash + +import kotlin.math.pow + +class SRGB { + companion object { + fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0.0f, 1.0f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + fun linearToSrgb(value: Double): Int { + val v = value.coerceIn(0.0, 1.0) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4) - 0.055f) * 255 + 0.5f).toInt() + } + } + + fun srgbToLinear(value: Int): Float { + val valueCheck = value.coerceIn(0, 255) + + val v = valueCheck / 255f + return if (v <= 0.04045f) { + v / 12.92f + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt index 2df08520b..ed2cbc9d5 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt @@ -307,7 +307,7 @@ class BiMaxOfCollector( v: V, ) { if (filter.filter(k, v)) { - if (maxK == null || comparator.compare(v, maxV) > 1) { + if (maxK == null || comparator.compare(v, maxV) > 0) { maxK = k maxV = v } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt index 52346e19d..022f4914c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt @@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.commons.hashtags import androidx.compose.ui.graphics.vector.ImageVector import kotlin.collections.List as ____KtList -public object CustomHashTagIcons +object CustomHashTagIcons private var customHashTagIconsAllIconsCache: ____KtList? = null -public val CustomHashTagIcons.AllIcons: ____KtList +val CustomHashTagIcons.AllIcons: ____KtList get() { if (customHashTagIconsAllIconsCache != null) { return customHashTagIconsAllIconsCache!! @@ -47,6 +47,7 @@ public val CustomHashTagIcons.AllIcons: ____KtList Zap, Tunestr, Nostr, + Gamestr, ) return customHashTagIconsAllIconsCache!! } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt new file mode 100644 index 000000000..38f34c5b9 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt @@ -0,0 +1,268 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.hashtags + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Preview +@Composable +fun CustomHashTagIconsGamestrPreview() { + Image( + painter = + rememberVectorPainter( + CustomHashTagIcons.Gamestr, + ), + contentDescription = "", + ) +} + +val CustomHashTagIcons.Gamestr: ImageVector + get() { + if (customHashTagIconsGamestr != null) { + return customHashTagIconsGamestr!! + } + customHashTagIconsGamestr = + Builder( + name = "Gamestr", + defaultWidth = 512.0.dp, + defaultHeight = 512.0.dp, + viewportWidth = 512.0f, + viewportHeight = 512.0f, + ).apply { + path( + fill = SolidColor(Color(0xFFED5564)), + ) { + moveTo(511.53f, 309.68f) + curveToRelative(-2.48f, -42.44f, -15.15f, -98.23f, -37.64f, -165.84f) + curveToRelative(-0.48f, -1.42f, -1.25f, -2.73f, -2.25f, -3.84f) + lineToRelative(-37.11f, -40.55f) + curveToRelative(-2.02f, -2.21f, -4.88f, -3.47f, -7.86f, -3.47f) + horizontalLineToRelative(-42.67f) + curveToRelative(-1.66f, 0.0f, -3.28f, 0.39f, -4.77f, 1.13f) + lineToRelative(-19.08f, 9.52f) + horizontalLineTo(151.84f) + lineToRelative(-19.09f, -9.52f) + curveToRelative(-1.47f, -0.73f, -3.11f, -1.13f, -4.76f, -1.13f) + horizontalLineTo(85.33f) + curveToRelative(-3.0f, 0.0f, -5.86f, 1.26f, -7.88f, 3.47f) + lineToRelative(-37.09f, 40.55f) + curveToRelative(-1.02f, 1.11f, -1.78f, 2.41f, -2.25f, 3.84f) + curveTo(15.61f, 211.45f, 2.94f, 267.25f, 0.46f, 309.68f) + curveToRelative(-2.16f, 36.75f, 3.31f, 64.5f, 16.26f, 82.44f) + curveToRelative(11.27f, 15.62f, 28.09f, 23.87f, 48.69f, 23.87f) + curveToRelative(14.97f, 0.0f, 28.55f, -6.81f, 40.37f, -20.28f) + curveToRelative(8.58f, -9.78f, 16.26f, -23.16f, 22.83f, -39.8f) + curveToRelative(7.19f, -18.17f, 11.47f, -36.17f, 13.59f, -46.58f) + horizontalLineToRelative(227.58f) + curveToRelative(2.13f, 10.41f, 6.41f, 28.41f, 13.59f, 46.58f) + curveToRelative(6.56f, 16.64f, 14.25f, 30.02f, 22.83f, 39.8f) + curveToRelative(11.83f, 13.47f, 25.42f, 20.28f, 40.38f, 20.28f) + curveToRelative(20.59f, 0.0f, 37.44f, -8.25f, 48.7f, -23.87f) + curveTo(508.22f, 374.18f, 513.68f, 346.43f, 511.53f, 309.68f) + close() + } + path( + fill = SolidColor(Color(0xFFE6E9ED)), + ) { + moveTo(412.88f, 178.2f) + curveToRelative(-4.17f, 4.17f, -10.92f, 4.17f, -15.08f, 0.0f) + curveToRelative(-4.17f, -4.16f, -4.17f, -10.91f, 0.0f, -15.09f) + curveToRelative(4.16f, -4.16f, 10.9f, -4.16f, 15.08f, 0.0f) + curveTo(417.05f, 167.29f, 417.05f, 174.04f, 412.88f, 178.2f) + close() + } + path( + fill = SolidColor(Color(0xFFE6E9ED)), + ) { + moveTo(412.88f, 220.87f) + curveToRelative(-4.17f, 4.16f, -10.92f, 4.16f, -15.08f, 0.0f) + curveToRelative(-4.17f, -4.16f, -4.17f, -10.92f, 0.0f, -15.09f) + curveToRelative(4.16f, -4.16f, 10.9f, -4.16f, 15.08f, 0.0f) + curveTo(417.05f, 209.95f, 417.05f, 216.71f, 412.88f, 220.87f) + close() + } + path( + fill = SolidColor(Color(0xFFE6E9ED)), + ) { + moveTo(434.2f, 199.54f) + curveToRelative(-4.16f, 4.16f, -10.91f, 4.16f, -15.08f, 0.0f) + reflectiveCurveToRelative(-4.17f, -10.91f, 0.0f, -15.09f) + curveToRelative(4.17f, -4.16f, 10.92f, -4.16f, 15.08f, 0.0f) + curveTo(438.38f, 188.62f, 438.38f, 195.37f, 434.2f, 199.54f) + close() + } + path( + fill = SolidColor(Color(0xFFE6E9ED)), + ) { + moveTo(391.55f, 199.54f) + curveToRelative(-4.17f, 4.16f, -10.92f, 4.16f, -15.09f, 0.0f) + curveToRelative(-4.16f, -4.16f, -4.16f, -10.91f, 0.0f, -15.09f) + curveToRelative(4.17f, -4.16f, 10.92f, -4.16f, 15.09f, 0.0f) + curveTo(395.7f, 188.62f, 395.7f, 195.37f, 391.55f, 199.54f) + close() + } + path( + fill = SolidColor(Color(0xFF656D78)), + ) { + moveTo(224.0f, 277.34f) + curveToRelative(0.0f, 23.56f, -19.11f, 42.65f, -42.67f, 42.65f) + reflectiveCurveToRelative(-42.67f, -19.09f, -42.67f, -42.65f) + curveToRelative(0.0f, -23.58f, 19.11f, -42.68f, 42.67f, -42.68f) + reflectiveCurveTo(224.0f, 253.76f, 224.0f, 277.34f) + close() + } + path( + fill = SolidColor(Color(0xFF434A54)), + ) { + moveTo(181.33f, 224.0f) + curveToRelative(-29.41f, 0.0f, -53.34f, 23.92f, -53.34f, 53.34f) + curveToRelative(0.0f, 29.4f, 23.94f, 53.33f, 53.34f, 53.33f) + reflectiveCurveToRelative(53.33f, -23.92f, 53.33f, -53.33f) + curveTo(234.65f, 247.92f, 210.73f, 224.0f, 181.33f, 224.0f) + close() + moveTo(181.33f, 309.34f) + curveToRelative(-17.64f, 0.0f, -32.0f, -14.38f, -32.0f, -32.0f) + curveToRelative(0.0f, -17.66f, 14.36f, -32.01f, 32.0f, -32.01f) + reflectiveCurveToRelative(32.0f, 14.35f, 32.0f, 32.01f) + curveTo(213.33f, 294.96f, 198.97f, 309.34f, 181.33f, 309.34f) + close() + } + path( + fill = SolidColor(Color(0xFF656D78)), + ) { + moveTo(373.33f, 277.34f) + curveToRelative(0.0f, 23.56f, -19.09f, 42.65f, -42.67f, 42.65f) + curveToRelative(-23.56f, 0.0f, -42.65f, -19.09f, -42.65f, -42.65f) + curveToRelative(0.0f, -23.58f, 19.09f, -42.68f, 42.65f, -42.68f) + curveTo(354.24f, 234.66f, 373.33f, 253.76f, 373.33f, 277.34f) + close() + } + path( + fill = SolidColor(Color(0xFF434A54)), + ) { + moveTo(330.66f, 224.0f) + curveToRelative(-29.41f, 0.0f, -53.33f, 23.92f, -53.33f, 53.34f) + curveToRelative(0.0f, 29.4f, 23.92f, 53.33f, 53.33f, 53.33f) + curveToRelative(29.42f, 0.0f, 53.34f, -23.92f, 53.34f, -53.33f) + curveTo(384.0f, 247.92f, 360.08f, 224.0f, 330.66f, 224.0f) + close() + moveTo(330.66f, 309.34f) + curveToRelative(-17.64f, 0.0f, -32.0f, -14.38f, -32.0f, -32.0f) + curveToRelative(0.0f, -17.66f, 14.36f, -32.01f, 32.0f, -32.01f) + curveToRelative(17.66f, 0.0f, 32.01f, 14.35f, 32.01f, 32.01f) + curveTo(362.67f, 294.96f, 348.31f, 309.34f, 330.66f, 309.34f) + close() + } + path( + fill = SolidColor(Color(0xFFF5F7FA)), + ) { + moveTo(106.66f, 224.0f) + curveToRelative(-5.89f, 0.0f, -10.67f, -4.77f, -10.67f, -10.67f) + verticalLineToRelative(-42.66f) + curveToRelative(0.0f, -5.89f, 4.78f, -10.66f, 10.67f, -10.66f) + reflectiveCurveToRelative(10.67f, 4.77f, 10.67f, 10.66f) + verticalLineToRelative(42.66f) + curveTo(117.33f, 219.23f, 112.55f, 224.0f, 106.66f, 224.0f) + close() + } + path( + fill = SolidColor(Color(0xFFE6E9ED)), + ) { + moveTo(127.99f, 202.66f) + horizontalLineTo(85.33f) + curveToRelative(-5.89f, 0.0f, -10.67f, -4.77f, -10.67f, -10.66f) + reflectiveCurveToRelative(4.78f, -10.67f, 10.67f, -10.67f) + horizontalLineToRelative(42.65f) + curveToRelative(5.89f, 0.0f, 10.67f, 4.78f, 10.67f, 10.67f) + curveTo(138.66f, 197.89f, 133.88f, 202.66f, 127.99f, 202.66f) + close() + } + path( + fill = SolidColor(Color(0xFFFFCE54)), + ) { + moveTo(181.32f, 106.64f) + horizontalLineToRelative(149.33f) + verticalLineToRelative(42.69f) + horizontalLineToRelative(-149.33f) + close() + } + path( + fill = SolidColor(Color(0xFF434A54)), + ) { + moveTo(495.28f, 392.12f) + curveToRelative(8.92f, -12.36f, 14.28f, -29.38f, 16.06f, -50.78f) + curveToRelative(-0.03f, 0.0f, -59.73f, 43.54f, -117.26f, 36.28f) + curveToRelative(0.0f, 0.0f, 0.05f, 0.41f, 0.17f, 1.11f) + curveToRelative(3.75f, 6.5f, 7.75f, 12.17f, 11.95f, 16.98f) + curveToRelative(11.83f, 13.47f, 25.42f, 20.28f, 40.38f, 20.28f) + curveTo(467.17f, 415.99f, 484.01f, 407.74f, 495.28f, 392.12f) + close() + } + path( + fill = SolidColor(Color(0xFF434A54)), + ) { + moveTo(16.72f, 392.12f) + curveToRelative(-8.92f, -12.36f, -14.3f, -29.38f, -16.06f, -50.78f) + curveToRelative(0.0f, 0.0f, 59.72f, 43.54f, 117.26f, 36.28f) + curveToRelative(0.0f, 0.0f, -0.06f, 0.41f, -0.17f, 1.11f) + curveToRelative(-3.77f, 6.5f, -7.75f, 12.17f, -11.97f, 16.98f) + curveToRelative(-11.83f, 13.47f, -25.41f, 20.28f, -40.37f, 20.28f) + curveTo(44.82f, 415.99f, 27.99f, 407.74f, 16.72f, 392.12f) + close() + } + path( + fill = SolidColor(Color(0xFF434A54)), + ) { + moveTo(70.88f, 106.64f) + horizontalLineToRelative(80.97f) + lineToRelative(-19.09f, -9.52f) + curveToRelative(-1.47f, -0.73f, -3.11f, -1.13f, -4.76f, -1.13f) + horizontalLineTo(85.33f) + curveToRelative(-3.0f, 0.0f, -5.86f, 1.26f, -7.88f, 3.47f) + lineTo(70.88f, 106.64f) + close() + } + path( + fill = SolidColor(Color(0xFF434A54)), + ) { + moveTo(441.13f, 106.64f) + horizontalLineToRelative(-80.95f) + lineToRelative(19.08f, -9.52f) + curveToRelative(1.48f, -0.73f, 3.11f, -1.13f, 4.77f, -1.13f) + horizontalLineToRelative(42.67f) + curveToRelative(2.98f, 0.0f, 5.84f, 1.26f, 7.86f, 3.47f) + lineTo(441.13f, 106.64f) + close() + } + }.build() + return customHashTagIconsGamestr!! + } + +private var customHashTagIconsGamestr: ImageVector? = null diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt index 793658b40..515446ef9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.hashtags +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType.Companion.NonZero import androidx.compose.ui.graphics.SolidColor @@ -28,9 +30,23 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -public val CustomHashTagIcons.Skull: ImageVector +@Preview +@Composable +fun CustomHashTagIconsSkullPreview() { + Image( + painter = + rememberVectorPainter( + CustomHashTagIcons.Skull, + ), + contentDescription = "", + ) +} + +val CustomHashTagIcons.Skull: ImageVector get() { if (customHashTagIconsSkull != null) { return customHashTagIconsSkull!! diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoder.kt deleted file mode 100644 index a95e9894d..000000000 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoder.kt +++ /dev/null @@ -1,334 +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.commons.preview - -import android.graphics.Bitmap -import android.graphics.Color -import kotlin.math.cos -import kotlin.math.pow -import kotlin.math.roundToInt -import kotlin.math.withSign - -object BlurHashDecoder { - // cache Math.cos() calculations to improve performance. - // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * - // 2 * nBitmaps - // the cache is enabled by default, it is recommended to disable it only when just a few images - // are displayed - private val cacheCosinesX = HashMap() - private val cacheCosinesY = HashMap() - - /** - * Clear calculations stored in memory cache. The cache is not big, but will increase when many - * image sizes are used, if the app needs memory it is recommended to clear it. - */ - fun clearCache() { - cacheCosinesX.clear() - cacheCosinesY.clear() - } - - /** Returns width/height */ - fun aspectRatio(blurHash: String?): Float? { - if (blurHash == null || blurHash.length < 6) { - return null - } - val numCompEnc = decode83At(blurHash, 0) - val numCompX = (numCompEnc % 9) + 1 - val numCompY = (numCompEnc / 9) + 1 - if (blurHash.length != 4 + 2 * numCompX * numCompY) { - return null - } - - return numCompX.toFloat() / numCompY.toFloat() - } - - /** - * Decode a blur hash into a new bitmap. - * - * @param useCache use in memory cache for the calculated math, reused by images with same size. - * if the cache does not exist yet it will be created and populated with new calculations. By - * default it is true. - */ - fun decodeKeepAspectRatio( - blurHash: String?, - width: Int, - useCache: Boolean = true, - ): Bitmap? { - if (blurHash == null || blurHash.length < 6) { - return null - } - val numCompEnc = decode83At(blurHash, 0) - val numCompX = (numCompEnc % 9) + 1 - val numCompY = (numCompEnc / 9) + 1 - if (blurHash.length != 4 + 2 * numCompX * numCompY) { - return null - } - val height = (100 * (1 / (numCompX.toFloat() / numCompY.toFloat()))).roundToInt() - val maxAc = (decode83At(blurHash, 1) + 1) / 166f - - val colors = - Array(numCompX * numCompY) { i -> - if (i == 0) { - decodeDc(decode83(blurHash, 2, 6)) - } else { - decodeAc(decode83Fixed2(blurHash, 4 + i * 2), maxAc) - } - } - return composeBitmap(width, height, numCompX, numCompY, colors, useCache) - } - - private fun decode83At( - str: String, - at: Int = 0, - ): Int = charMap[str[at].code] - - private fun decode83Fixed2( - str: String, - from: Int = 0, - ): Int = charMap[str[from].code] * 83 + charMap[str[from + 1].code] - - private fun decode83( - str: String, - from: Int = 0, - to: Int = str.length, - ): Int { - var result = 0 - for (i in from until to) { - result = result * 83 + charMap[str[i].code] - } - return result - } - - private fun decodeDc(colorEnc: Int): FloatArray { - val r = colorEnc shr 16 - val g = (colorEnc shr 8) and 255 - val b = colorEnc and 255 - return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) - } - - private fun srgbToLinear(colorEnc: Int): Float { - val v = colorEnc / 255f - return if (v <= 0.04045f) { - (v / 12.92f) - } else { - ((v + 0.055f) / 1.055f).pow(2.4f) - } - } - - private fun decodeAc( - value: Int, - maxAc: Float, - ): FloatArray { - val r = value / (19 * 19) - val g = (value / 19) % 19 - val b = value % 19 - return floatArrayOf( - signedPow2((r - 9) / 9.0f) * maxAc, - signedPow2((g - 9) / 9.0f) * maxAc, - signedPow2((b - 9) / 9.0f) * maxAc, - ) - } - - private fun signedPow2(value: Float) = value.pow(2f).withSign(value) - - private fun composeBitmap( - width: Int, - height: Int, - numCompX: Int, - numCompY: Int, - colors: Array, - useCache: Boolean, - ): Bitmap { - // use an array for better performance when writing pixel colors - val imageArray = IntArray(width * height) - val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) - val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) - val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) - val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) - - var r = 0.0f - var g = 0.0f - var b = 0.0f - - for (y in 0 until height) { - for (x in 0 until width) { - r = 0.0f - g = 0.0f - b = 0.0f - for (j in 0 until numCompY) { - for (i in 0 until numCompX) { - val cosY = cosinesY[j + numCompY * y] - val cosX = cosinesX[i + numCompX * x] - val basis = (cosX * cosY).toFloat() - val color = colors[j * numCompX + i] - r += color[0] * basis - g += color[1] * basis - b += color[2] * basis - } - } - - imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) - } - } - return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) - } - - private fun getArrayForCosinesY( - calculate: Boolean, - height: Int, - numCompY: Int, - ) = when { - calculate -> { - DoubleArray(height * numCompY) { - val y = it / numCompY - val j = it % numCompY - cos(Math.PI * y * j / height) - }.also { - cacheCosinesY[height * numCompY] = it - } - } - else -> { - cacheCosinesY[height * numCompY]!! - } - } - - private fun getArrayForCosinesX( - calculate: Boolean, - width: Int, - numCompX: Int, - ) = when { - calculate -> { - DoubleArray(width * numCompX) { - val x = it / numCompX - val i = it % numCompX - cos(Math.PI * x * i / width) - }.also { cacheCosinesX[width * numCompX] = it } - } - else -> cacheCosinesX[width * numCompX]!! - } - - private fun linearToSrgb(value: Float): Int { - val v = value.coerceIn(0f, 1f) - return if (v <= 0.0031308f) { - (v * 12.92f * 255f + 0.5f).toInt() - } else { - ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() - } - } - - private val linToSrgbApproximation = - Array(255) { - linearToSrgb(it / 255f) - } - - private val charMap = - listOf( - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - 'A', - 'B', - 'C', - 'D', - 'E', - 'F', - 'G', - 'H', - 'I', - 'J', - 'K', - 'L', - 'M', - 'N', - 'O', - 'P', - 'Q', - 'R', - 'S', - 'T', - 'U', - 'V', - 'W', - 'X', - 'Y', - 'Z', - 'a', - 'b', - 'c', - 'd', - 'e', - 'f', - 'g', - 'h', - 'i', - 'j', - 'k', - 'l', - 'm', - 'n', - 'o', - 'p', - 'q', - 'r', - 's', - 't', - 'u', - 'v', - 'w', - 'x', - 'y', - 'z', - '#', - '$', - '%', - '*', - '+', - ',', - '-', - '.', - ':', - ';', - '=', - '?', - '@', - '[', - ']', - '^', - '_', - '{', - '|', - '}', - '~', - ).mapIndexed { i, c -> c.code to i } - .toMap() - .let { charMap -> - Array(255) { - charMap[it] ?: 0 - } - } -} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt index e537ce840..1925c7931 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt @@ -27,26 +27,38 @@ class ExpandableTextCutOffCalculator { private const val TOO_FAR_SEARCH_THE_OTHER_WAY = 450 fun indexToCutOff(content: String): Int { - // Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters + // Cuts the text in the first space or first new line after SHORT_TEXT_LENGTH characters val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + + // Cuts the text if too many new lines have passed. val firstLineAfterLineLimits = content.nthIndexOf('\n', SHORTEN_AFTER_LINES).let { if (it < 0) content.length else it } + // gets the minimum of them all. val min = minOf(firstSpaceAfterCut, firstNewLineAfterCut, firstLineAfterLineLimits) - if (min > TOO_FAR_SEARCH_THE_OTHER_WAY) { - val newString = content.take(SHORT_TEXT_LENGTH) - val firstSpaceBeforeCut = - newString.lastIndexOf(' ').let { if (it < 0) content.length else it } - val firstNewLineBeforeCut = - newString.lastIndexOf('\n').let { if (it < 0) content.length else it } + val result = + if (min > TOO_FAR_SEARCH_THE_OTHER_WAY) { + // if it is still too big, finds the first space or new line BEFORE the cut off. + val newString = content.take(SHORT_TEXT_LENGTH) + val firstSpaceBeforeCut = + newString.lastIndexOf(' ').let { if (it < 0) content.length else it } + val firstNewLineBeforeCut = + newString.lastIndexOf('\n').let { if (it < 0) content.length else it } - return maxOf(firstSpaceBeforeCut, firstNewLineBeforeCut) + maxOf(firstSpaceBeforeCut, firstNewLineBeforeCut) + } else { + min + } + + // Only returns if the difference between short and long posts is more than 100 chars or too many new lines. + return if (result == firstLineAfterLineLimits || result + 100 < content.length) { + result } else { - return min + content.length } } } 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 acee3f8ee..e7dee4dba 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,12 +21,13 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.Dimension import java.io.File @Immutable abstract class BaseMediaContent( val description: String? = null, - val dim: String? = null, + val dim: Dimension? = null, val blurhash: String? = null, ) @@ -35,30 +36,44 @@ abstract class MediaUrlContent( val url: String, description: String? = null, val hash: String? = null, - dim: String? = null, + dim: Dimension? = null, blurhash: String? = null, val uri: String? = null, val mimeType: String? = null, ) : BaseMediaContent(description, dim, blurhash) @Immutable -class MediaUrlImage( +open class MediaUrlImage( url: String, description: String? = null, hash: String? = null, blurhash: String? = null, - dim: String? = null, + dim: Dimension? = null, uri: String? = null, val contentWarning: String? = null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) -@Immutable -class MediaUrlVideo( +class EncryptedMediaUrlImage( url: String, description: String? = null, hash: String? = null, - dim: String? = null, + blurhash: String? = null, + dim: Dimension? = null, + uri: String? = null, + contentWarning: String? = null, + mimeType: String? = null, + val encryptionAlgo: String, + val encryptionKey: ByteArray, + val encryptionNonce: ByteArray, +) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType) + +@Immutable +open class MediaUrlVideo( + url: String, + description: String? = null, + hash: String? = null, + dim: Dimension? = null, uri: String? = null, val artworkUri: String? = null, val authorName: String? = null, @@ -67,13 +82,30 @@ class MediaUrlVideo( mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) +@Immutable +class EncryptedMediaUrlVideo( + url: String, + description: String? = null, + hash: String? = null, + dim: Dimension? = null, + uri: String? = null, + artworkUri: String? = null, + authorName: String? = null, + blurhash: String? = null, + contentWarning: String? = null, + mimeType: String? = null, + val encryptionAlgo: String, + val encryptionKey: ByteArray, + val encryptionNonce: ByteArray, +) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType) + @Immutable abstract class MediaPreloadedContent( val localFile: File?, description: String? = null, val mimeType: String? = null, val isVerified: Boolean? = null, - dim: String? = null, + dim: Dimension? = null, blurhash: String? = null, val uri: String, val id: String? = null, @@ -86,7 +118,7 @@ class MediaLocalImage( localFile: File?, mimeType: String? = null, description: String? = null, - dim: String? = null, + dim: Dimension? = null, blurhash: String? = null, isVerified: Boolean? = null, uri: String, @@ -97,7 +129,7 @@ class MediaLocalVideo( localFile: File?, mimeType: String? = null, description: String? = null, - dim: String? = null, + dim: Dimension? = 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 8581d9eb0..36ac18a77 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,6 +24,7 @@ import android.util.Log import android.util.Patterns import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments @@ -42,58 +43,54 @@ import java.util.regex.Pattern import kotlin.coroutines.cancellation.CancellationException class RichTextParser { - fun createImageContent( - fullUrl: String, - eventTags: ImmutableListOfLists, - description: String?, - callbackUri: String? = null, - ): MediaUrlImage { - val frags = Nip54InlineMetadata().parse(fullUrl) - val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists) - - return 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] ?: tags[FileHeaderEvent.DIMENSION], - contentWarning = frags["content-warning"] ?: tags["content-warning"], - uri = callbackUri, - mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE], - ) - } - - fun createVideoContent( - fullUrl: String, - eventTags: ImmutableListOfLists, - description: String?, - callbackUri: String? = null, - ): MediaUrlVideo { - val frags = Nip54InlineMetadata().parse(fullUrl) - val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists) - return 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] ?: tags[FileHeaderEvent.DIMENSION], - contentWarning = frags["content-warning"] ?: tags["content-warning"], - uri = callbackUri, - mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE], - ) - } - - fun parseMediaUrl( + fun createMediaContent( fullUrl: String, eventTags: ImmutableListOfLists, description: String?, callbackUri: String? = null, ): MediaUrlContent? { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - createImageContent(fullUrl, eventTags, description, callbackUri) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - createVideoContent(fullUrl, eventTags, description, callbackUri) + val frags = Nip54InlineMetadata().parse(fullUrl) + val tags = Nip92MediaAttachments.parse(fullUrl, eventTags.lists) + + val contentType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE] + + val isImage: Boolean + val isVideo: Boolean + + if (contentType != null) { + isImage = contentType.startsWith("image/") + isVideo = contentType.startsWith("video/") + } else if (fullUrl.startsWith("data:")) { + isImage = fullUrl.startsWith("data:image/") + isVideo = fullUrl.startsWith("data:video/") + } else { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) + isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + } + + 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"], + 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"], + uri = callbackUri, + mimeType = contentType, + ) } else { null } @@ -136,7 +133,7 @@ class RichTextParser { val urlSet = parseValidUrls(content) val imagesForPager = - urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags, content, callbackUri) }.associateBy { it.url } + urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, tags, content, callbackUri) }.associateBy { it.url } val emojiMap = Nip30CustomEmoji.createEmojiMap(tags) @@ -147,7 +144,7 @@ class RichTextParser { val imagesForPagerWithBase64 = imagesForPager + base64Images - .map { createImageContent(it.segmentText, tags, content, callbackUri) } + .mapNotNull { createMediaContent(it.segmentText, tags, content, callbackUri) } .associateBy { it.url } return RichTextViewerState( @@ -347,8 +344,11 @@ class RichTextParser { "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?" .toRegex(RegexOption.IGNORE_CASE) - val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") - val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") + val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") + val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") + + val imageExtensions = imageExt + imageExt.map { it.uppercase() } + val videoExtensions = videoExt + videoExt.map { it.uppercase() } val base64contentPattern = Pattern.compile("data:image/(${imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") 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 0a45343af..10b89ae2f 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,9 +85,9 @@ 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.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexValidator -import java.security.MessageDigest val Black = SolidColor(Color.Black) val Gray = SolidColor(Color(0xFF6d6e70)) @@ -165,11 +165,11 @@ class RobohashAssembler { isLightTheme: Boolean, ): ImageVector { val hash = - if (HexValidator.isHex(msg)) { + if (HexValidator.isHex(msg) && msg.length > 10) { Hex.decode(msg) } else { Log.w("Robohash", "$msg is not a hex") - MessageDigest.getInstance("SHA-256").digest(msg.toByteArray()) + CryptoUtils.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/Base83Test.kt b/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt new file mode 100644 index 000000000..8692361f8 --- /dev/null +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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 + +import com.vitorpamplona.amethyst.commons.blurhash.Base83 +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class Base83Test { + @Test + fun testEncodeDecode() { + for (i in 0..820000) { + assertEquals("$i encode decode", i, Base83.decode(Base83.encode(i.toLong()))) + } + } + + @Test + fun testSingleDigits() { + for (i in 0..82) { + val expected: String = String(Base83.ALPHABET, i, 1) + assertEquals("$i encodes", expected, Base83.encode(i.toLong(), 1)) + } + } + + @Test + fun test0000() { + assertEquals("0000", Base83.encode(0, 4)) + } + + @Test + fun test0001() { + assertEquals("0001", Base83.encode(1, 4)) + } + + @Test + fun test0010() { + assertEquals("0010", Base83.encode(83, 4)) + } + + @Test + fun test0011() { + assertEquals("0011", Base83.encode(83 + 1, 4)) + } + + @Test + fun test00X0() { + assertEquals("00~0", Base83.encode(83 * 82, 4)) + } + + @Test + fun test0100() { + assertEquals("0100", Base83.encode(83 * 83, 4)) + } + + @Test + fun test00XXEncode() { + assertEquals("00~~", Base83.encode(83 * 82 + 82, 4)) + } + + @Test + fun test0XXXDecode() { + assertEquals(82 + 82 * 83 + 82 * 83 * 83, Base83.decode("0~~~")) + } +} diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt b/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt new file mode 100644 index 000000000..0fe89dd49 --- /dev/null +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.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.commons + +import com.vitorpamplona.amethyst.commons.blurhash.SRGB +import junit.framework.TestCase.assertEquals +import org.junit.Test +import kotlin.math.round + +class SRGBTest { + @Test + fun testEncodeDecode() { + for (i in 0..255) { + assertEquals("$i encode decode", i, SRGB.linearToSrgb(SRGB.srgbToLinear(i))) + } + + for (i in 0..100) { + val srgb = SRGB.linearToSrgb(i / 100.0f) + val linear = round(SRGB.srgbToLinear(srgb) * 100).toInt() + + assertEquals("$i decode encode", i, linear) + } + } +} diff --git a/docs/design/zapstore.svg b/docs/design/zapstore.svg new file mode 100644 index 000000000..0afee4aa6 --- /dev/null +++ b/docs/design/zapstore.svg @@ -0,0 +1,237 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 50d7abc5e..426ac371f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -accompanistAdaptive = "0.34.0" +accompanistAdaptive = "0.37.0" activityCompose = "1.9.3" -agp = "8.7.1" +agp = "8.7.3" android-compileSdk = "35" android-minSdk = "26" android-targetSdk = "35" @@ -12,48 +12,47 @@ audiowaveform = "1.1.1" benchmark = "1.3.3" benchmarkJunit4 = "1.3.3" biometricKtx = "1.2.0-alpha05" -blurhash = "1.0.0" -coil = "3.0.0-rc02" -composeBom = "2024.10.01" +coil = "3.0.4" +composeBom = "2024.12.01" coreKtx = "1.15.0" espressoCore = "3.6.1" -firebaseBom = "33.5.1" +firebaseBom = "33.7.0" fragmentKtx = "1.8.5" gms = "4.4.2" -jacksonModuleKotlin = "2.17.2" -jna = "5.14.0" +jacksonModuleKotlin = "2.18.2" +jna = "5.15.0" jtorctl = "0.4.5.7" junit = "4.13.2" -kotlin = "2.0.20" -kotlinxCollectionsImmutable = "0.3.7" -kotlinxSerialization = "1.7.2" +kotlin = "2.1.0" +kotlinxCollectionsImmutable = "0.3.8" +kotlinxSerialization = "1.7.3" kotlinxSerializationPlugin = "2.0.0" languageId = "17.0.6" lazysodiumAndroid = "5.1.0" lifecycleRuntimeKtx = "2.8.7" lightcompressor = "1.3.2" markdown = "077a2cde64" -media3 = "1.4.1" -mockk = "1.13.12" -kotlinx-coroutines-test = "1.9.0-RC.2" -navigationCompose = "2.8.3" +media3 = "1.5.1" +mockk = "1.13.14" +kotlinx-coroutines-test = "1.10.1" +navigationCompose = "2.8.5" okhttp = "5.0.0-alpha.14" runner = "1.6.2" rfc3986 = "0.1.0" -secp256k1KmpJniAndroid = "0.15.0" +secp256k1KmpJniAndroid = "0.16.0" 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.15.0" +vico-charts = "1.16.0" zelory = "3.0.1" -zoomable = "1.6.2" +zoomable = "2.0.0" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.3.0" -androidxCamera = "1.4.0" +androidxCamera = "1.4.1" [libraries] abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } @@ -123,7 +122,6 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt 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" } tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" } -trbl-blurhash = { group = "io.trbl", name = "blurhash", version.ref = "blurhash" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts" } diff --git a/quartz/build.gradle b/quartz/build.gradle index 55a32d91b..555e40a9b 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -59,7 +59,7 @@ dependencies { // LibSodium for ChaCha encryption (NIP-44) // Wait for @aar support in version catalogs implementation "com.goterl:lazysodium-android:5.1.0@aar" - implementation 'net.java.dev.jna:jna:5.14.0@aar' + implementation 'net.java.dev.jna:jna:5.15.0@aar' //implementation (libs.lazysodium.android) { artifact { type = "aar" } } //implementation (libs.jna) { artifact { type = "aar" } } @@ -73,7 +73,7 @@ dependencies { // Parses URLs from Text: api libs.url.detector - // Parses URLs from Text: + // Normalizes URLs api libs.rfc3986.normalizer testImplementation libs.junit diff --git a/quartz/src/androidTest/assets/ovxxk2vz.jpg b/quartz/src/androidTest/assets/ovxxk2vz.jpg new file mode 100644 index 000000000..bc96672af Binary files /dev/null and b/quartz/src/androidTest/assets/ovxxk2vz.jpg differ diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip17/AESGCMTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip17/AESGCMTest.kt new file mode 100644 index 000000000..a53868404 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip17/AESGCMTest.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.crypto.nip17 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.vitorpamplona.quartz.crypto.CryptoUtils.decrypt +import com.vitorpamplona.quartz.encoders.hexToByteArray +import junit.framework.TestCase.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AESGCMTest { + val decryptionNonce = "01e77c94bd5aba3e3cbb69594e7ba07c" + val decryptionKey = "c128ecffab90ee7810e3df08e7fb2cc39a8d40f24201f48b2b36e23b34ac50ee" + + val cipher = AESGCM(decryptionKey.hexToByteArray(), decryptionNonce.toByteArray(Charsets.UTF_8)) + + @Test + fun encryptDecrypt() { + val encrypted = cipher.encrypt("Testing".toByteArray(Charsets.UTF_8)) + val decrypted = cipher.decrypt(encrypted) + + assertEquals("Testing", String(decrypted)) + } + + @Test + fun imageTest() { + val image = + getInstrumentation().context.assets.open("ovxxk2vz.jpg").use { + it.readAllBytes() + } + + val decrypted = cipher.decrypt(image) + + assertEquals(44201, decrypted.size) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/encoders/HexEncodingTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/encoders/HexEncodingTest.kt index 485444251..9f0185310 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/encoders/HexEncodingTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/encoders/HexEncodingTest.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.encoders import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.crypto.CryptoUtils import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -69,4 +71,43 @@ class HexEncodingTest { ) } } + + @Test + fun testIsHex() { + assertFalse("/0", HexValidator.isHex("/0")) + assertFalse("/.", HexValidator.isHex("/.")) + assertFalse("!!", HexValidator.isHex("!!")) + assertFalse("::", HexValidator.isHex("::")) + assertFalse("@@", HexValidator.isHex("@@")) + assertFalse("GG", HexValidator.isHex("GG")) + assertFalse("FG", HexValidator.isHex("FG")) + assertFalse("`a", HexValidator.isHex("`a")) + assertFalse("gg", HexValidator.isHex("gg")) + assertFalse("fg", HexValidator.isHex("fg")) + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRandomsIsHex() { + for (i in 0..10000) { + val bytes = CryptoUtils.privkeyCreate() + val hex = bytes.toHexString(HexFormat.Default) + assertTrue(hex, HexValidator.isHex(hex)) + val hexUpper = bytes.toHexString(HexFormat.UpperCase) + assertTrue(hexUpper, HexValidator.isHex(hexUpper)) + } + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRandomsUppercase() { + for (i in 0..1000) { + val bytes = CryptoUtils.privkeyCreate() + val hex = bytes.toHexString(HexFormat.UpperCase) + assertEquals( + bytes.toList(), + Hex.decode(hex).toList(), + ) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/nip17/AESGCM.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/nip17/AESGCM.kt new file mode 100644 index 000000000..bea2684d0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/nip17/AESGCM.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.crypto.nip17 + +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.toHexKey +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +interface NostrCipher { + fun name(): String + + fun encrypt(bytesToEncrypt: ByteArray): ByteArray + + fun decrypt(bytesToDecrypt: ByteArray): ByteArray +} + +class AESGCM( + val keyBytes: ByteArray = CryptoUtils.random(32), + val nonce: ByteArray = CryptoUtils.random(16), +) : NostrCipher { + private fun newCipher() = Cipher.getInstance("AES/GCM/NoPadding") + + private fun keySpec() = SecretKeySpec(keyBytes, "AES") + + private fun param() = GCMParameterSpec(128, nonce) + + override fun name() = NAME + + fun copyUsingUTF8Nonce(): AESGCM = + AESGCM( + keyBytes, + nonce.toHexKey().toByteArray(Charsets.UTF_8), + ) + + 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) + } + + companion object { + const val NAME = "aes-gcm" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt index 7c777b04b..b2165e543 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt @@ -24,14 +24,25 @@ import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @Immutable data class ATag( val kind: Int, val pubKeyHex: String, val dTag: String, - val relay: String?, ) { + var relay: String? = null + + constructor( + kind: Int, + pubKeyHex: String, + 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 @@ -41,11 +52,15 @@ data class ATag( fun toTag() = assembleATag(kind, pubKeyHex, dTag) - fun toNAddr(): String = + fun toATagArray() = removeTrailingNullsAndEmptyOthers("a", toTag(), relay) + + fun toQTagArray() = removeTrailingNullsAndEmptyOthers("q", toTag(), relay) + + fun toNAddr(overrideRelay: String? = relay): String = TlvBuilder() .apply { addString(Nip19Bech32.TlvTypes.SPECIAL, dTag) - addStringIfNotNull(Nip19Bech32.TlvTypes.RELAY, relay) + addStringIfNotNull(Nip19Bech32.TlvTypes.RELAY, overrideRelay ?: relay) addHex(Nip19Bech32.TlvTypes.AUTHOR, pubKeyHex) addInt(Nip19Bech32.TlvTypes.KIND, kind) }.build() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Dimension.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Dimension.kt new file mode 100644 index 000000000..99bcbb980 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Dimension.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.encoders + +class Dimension( + val width: Int, + val height: Int, +) { + fun aspectRatio() = width.toFloat() / height.toFloat() + + fun hasSize() = width > 0 && height > 0 + + override fun toString() = "${width}x$height" + + companion object { + fun parse(dim: String): Dimension? { + if (dim == "0x0") return null + + val parts = dim.split("x") + if (parts.size != 2) return null + + return try { + val width = parts[0].toInt() + val height = parts[1].toInt() + + Dimension(width, height) + } catch (e: Exception) { + null + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ETag.kt new file mode 100644 index 000000000..00f84e2c5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ETag.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.encoders + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers + +@Immutable +data class ETag( + val eventId: HexKey, +) { + var relay: String? = null + var authorPubKeyHex: HexKey? = null + + constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.authorPubKeyHex = authorPubKeyHex + } + + fun countMemory(): Long = + 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) + eventId.bytesUsedInMemory() + + (relay?.bytesUsedInMemory() ?: 0) + + fun toNEvent(): String = Nip19Bech32.createNEvent(eventId, authorPubKeyHex, null, relay) + + fun toETagArray() = removeTrailingNullsAndEmptyOthers("e", eventId, relay, authorPubKeyHex) + + fun toQTagArray() = removeTrailingNullsAndEmptyOthers("q", eventId, relay, authorPubKeyHex) + + companion object { + fun parseNIP19(nevent: String): ETag? { + try { + val parsed = Nip19Bech32.uriToRoute(nevent)?.entity + + return when (parsed) { + is Nip19Bech32.Note -> ETag(parsed.hex) + is Nip19Bech32.NEvent -> ETag(parsed.hex, parsed.author, parsed.relay.firstOrNull()) + else -> null + } + } catch (e: Throwable) { + Log.w("PTag", "Issue trying to Decode NIP19 $this: ${e.message}") + return null + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/EventHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/EventHint.kt new file mode 100644 index 000000000..2f185a621 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/EventHint.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.encoders + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +data class EventHint( + val event: T, +) { + var relay: String? = null + + constructor(event: T, relayHint: String? = null) : this(event) { + this.relay = relayHint + } + + fun countMemory(): Long = + 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) + event.countMemory() + + (relay?.bytesUsedInMemory() ?: 0) + + fun toNEvent(): String = Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay) + + fun toNPub(): String = Nip19Bech32.createNPub(event.id) + + fun toTagArray(tag: String) = listOfNotNull(tag, event.id, relay, event.pubKey).toTypedArray() + + fun toETagArray() = toTagArray("e") + + fun toQTagArray() = toTagArray("q") +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt index 092cba20a..9f87a658d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt @@ -27,65 +27,53 @@ fun ByteArray.toHexKey(): HexKey = Hex.encode(this) fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this) -object HexValidator { - private fun isHexChar(c: Char): Boolean = - when (c) { - in '0'..'9' -> true - in 'a'..'f' -> true - in 'A'..'F' -> true - else -> false - } +val lowerCaseHex = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') +val upperCaseHex = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') +val hexToByte: IntArray = + IntArray(256) { -1 }.apply { + lowerCaseHex.forEachIndexed { index, char -> this[char.code] = index } + upperCaseHex.forEachIndexed { index, char -> this[char.code] = index } + } + +// Encodes both chars in a single Int variable +val byteToHex = + IntArray(256) { + (lowerCaseHex[(it shr 4)].code shl 8) or lowerCaseHex[(it and 0xF)].code + } + +object HexValidator { fun isHex(hex: String?): Boolean { if (hex == null) return false if (hex.isEmpty()) return false - if (hex.length % 2 != 0) return false // must be even - var isHex = true + if (hex.length and 1 != 0) return false // must be even - for (c in hex) { - if (!isHexChar(c)) { - isHex = false - break - } + for (c in hex.indices) { + if (hexToByte[hex[c].code] < 0) return false } - return isHex + + return true } } object Hex { - val hexCode = - arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') - - // Faster if no calculations are needed. - private fun hexToBin(ch: Char): Int = - when (ch) { - in '0'..'9' -> ch - '0' - in 'a'..'f' -> ch - 'a' + 10 - in 'A'..'F' -> ch - 'A' + 10 - else -> throw IllegalArgumentException("illegal hex character: $ch") - } - @JvmStatic fun decode(hex: String): ByteArray { // faster version of hex decoder - require(hex.length % 2 == 0) - val outSize = hex.length / 2 - val out = ByteArray(outSize) - - for (i in 0 until outSize) { - out[i] = (hexToBin(hex[2 * i]) * 16 + hexToBin(hex[2 * i + 1])).toByte() + require(hex.length and 1 == 0) + return ByteArray(hex.length / 2) { + (hexToByte[hex[2 * it].code] shl 4 or hexToByte[hex[2 * it + 1].code]).toByte() } - - return out } @JvmStatic fun encode(input: ByteArray): String { - val len = input.size - val out = CharArray(len * 2) - for (i in 0 until len) { - out[i * 2] = hexCode[(input[i].toInt() shr 4) and 0xF] - out[i * 2 + 1] = hexCode[input[i].toInt() and 0xF] + val out = CharArray(input.size * 2) + var outIdx = 0 + for (i in 0 until input.size) { + val chars = byteToHex[input[i].toInt() and 0xFF] + out[outIdx++] = (chars shr 8).toChar() + out[outIdx++] = (chars and 0xFF).toChar() } return String(out) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt index 06dc00ed0..1e0953792 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt @@ -270,6 +270,11 @@ object Nip19Bech32 { }.build() .toNEvent() + @Deprecated("Use nevent instead") + fun createNote(eventId: HexKey): String = eventId.hexToByteArray().toNote() + + fun createNPub(authorPubKeyHex: HexKey): String = authorPubKeyHex.hexToByteArray().toNpub() + fun createNProfile( authorPubKeyHex: String, relay: List, @@ -299,6 +304,7 @@ fun ByteArray.toNsec() = Bech32.encodeBytes(hrp = "nsec", this, Bech32.Encoding. fun ByteArray.toNpub() = Bech32.encodeBytes(hrp = "npub", this, Bech32.Encoding.Bech32) +@Deprecated("Prefer nevent1 instead") fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32) fun ByteArray.toNEvent() = Bech32.encodeBytes(hrp = "nevent", this, Bech32.Encoding.Bech32) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt index f49529869..d2bd7d9d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt @@ -20,43 +20,36 @@ */ package com.vitorpamplona.quartz.encoders -import com.vitorpamplona.quartz.events.FileHeaderEvent import java.net.URI import java.net.URLDecoder import java.net.URLEncoder import kotlin.coroutines.cancellation.CancellationException class Nip54InlineMetadata { - fun convertFromFileHeader(header: FileHeaderEvent): String? { - val myUrl = header.url() ?: return null - return createUrl( - myUrl, - header.tags, + fun createUrl(header: IMetaTag): String = + createUrl( + header.url, + header.properties, ) - } fun createUrl( - imageUrl: String, - tags: Array>, + url: String, + tags: Map, ): String { val extension = tags .mapNotNull { - if (it.isNotEmpty() && it[0] != "url") { - if (it.size > 1) { - "${it[0]}=${URLEncoder.encode(it[1], "utf-8")}" - } else { - "${it[0]}}=" - } + if (it.key != "url") { + "${it.key}=${URLEncoder.encode(it.value, "utf-8")}" } else { null } }.joinToString("&") - return if (imageUrl.contains("#")) { - "$imageUrl&$extension" + return if (url.contains("#")) { + "$url&$extension" } else { - "$imageUrl#$extension" + "$url#$extension" } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt index 6bfc6ece1..52a13e456 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt @@ -20,56 +20,108 @@ */ package com.vitorpamplona.quartz.encoders -import com.vitorpamplona.quartz.events.FileHeaderEvent +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.ALT +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.BLUR_HASH +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.DIMENSION +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.FILE_SIZE +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.HASH +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.MAGNET_URI +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.MIME_TYPE +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.ORIGINAL_HASH +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.TORRENT_INFOHASH + +class IMetaTag( + val url: String, + val properties: Map, +) + +class IMetaTagBuilder( + val url: String, +) { + val properties = mutableMapOf() + + fun add( + key: String, + value: String, + ): IMetaTagBuilder { + properties.set(key, 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) +} class Nip92MediaAttachments { companion object { - private const val IMETA = "imeta" - } + const val IMETA = "imeta" - fun convertFromFileHeader(header: FileHeaderEvent): Array? { - val myUrl = header.url() ?: return null - return createTag( - myUrl, - header.tags, - ) - } + fun createTag(header: IMetaTag): Array = + createTag( + header.url, + header.properties, + ) - fun createTag( - imageUrl: String, - tags: Array>, - ): Array = - arrayOf( - IMETA, - "url $imageUrl", - ) + - tags.mapNotNull { - if (it.isNotEmpty() && it[0] != "url") { - if (it.size > 1) { - "${it[0]} ${it[1]}" + fun createTag( + url: String, + tags: Map, + ): Array = + arrayOf( + IMETA, + "url $url", + ) + + tags.mapNotNull { + if (it.key != "url") { + "${it.key} ${it.value}" } else { - "${it[0]}}" + null } - } 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 } - fun parse( - imageUrl: String, - tags: Array>, - ): Map = - tags - .firstOrNull { - it.size > 1 && it[0] == IMETA && it[1] == "url $imageUrl" - }?.let { tagList -> - tagList.associate { tag -> - val parts = tag.split(" ", limit = 2) - when (parts.size) { - 2 -> parts[0] to parts[1] - 1 -> parts[0] to "" - else -> "" to "" - } + 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 "" } - } ?: emptyMap() + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/PTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/PTag.kt new file mode 100644 index 000000000..c1304b5c9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/PTag.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.encoders + +import android.util.Log +import androidx.compose.runtime.Immutable +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, +) { + 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() + + (relay?.bytesUsedInMemory() ?: 0) + + fun toNProfile(): String = Nip19Bech32.createNProfile(pubKeyHex, relay?.let { listOf(it) } ?: emptyList()) + + fun toNPub(): String = Nip19Bech32.createNPub(pubKeyHex) + + fun toPTagArray() = removeTrailingNullsAndEmptyOthers("p", pubKeyHex, relay) + + companion object { + fun parseNAddr(nprofile: String): PTag? { + try { + val parsed = Nip19Bech32.uriToRoute(nprofile)?.entity + + return when (parsed) { + is Nip19Bech32.NPub -> PTag(parsed.hex) + is Nip19Bech32.NProfile -> PTag(parsed.hex, parsed.relay.firstOrNull()) + else -> null + } + } catch (e: Throwable) { + Log.w("PTag", "Issue trying to Decode NIP19 $this: ${e.message}") + return null + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt index da32fc63d..cce5b9fb5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt @@ -45,8 +45,8 @@ class AppMetadata { var website: String? = null var about: String? = null var subscription: Boolean? = false - var cashuAccepted: Boolean? = false - var encryptionSupported: Boolean? = false + var acceptsNutZaps: Boolean? = false + var supportsEncryption: Boolean? = false var personalized: Boolean? = false var amount: String? = null @@ -71,8 +71,8 @@ class AppMetadata { (website?.bytesUsedInMemory() ?: 0L) + (about?.bytesUsedInMemory() ?: 0L) + (subscription?.bytesUsedInMemory() ?: 0L) + - (cashuAccepted?.bytesUsedInMemory() ?: 0L) + - (encryptionSupported?.bytesUsedInMemory() ?: 0L) + + (acceptsNutZaps?.bytesUsedInMemory() ?: 0L) + + (supportsEncryption?.bytesUsedInMemory() ?: 0L) + (personalized?.bytesUsedInMemory() ?: 0L) + // A Boolean has 8 bytes of header, plus 1 byte of payload, for a total of 9 bytes of information. The JVM then rounds it up to the next multiple of 8. so the one instance of java.lang.Boolean takes up 16 bytes of memory. (amount?.bytesUsedInMemory() ?: 0L) + (nip05?.bytesUsedInMemory() ?: 0L) + @@ -95,7 +95,7 @@ class AppMetadata { fun nip05(): String? = nip05 - fun profilePicture(): String? = picture + fun profilePicture(): String? = picture ?: image fun cleanBlankNames() { if (picture?.isNotEmpty() == true) picture = picture?.trim() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt index e9c0c8f7e..a0ed55bb3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt @@ -57,21 +57,15 @@ open class BaseTextNoteEvent( fun isForkFromAddressWithPubkey(authorHex: HexKey) = tags.any { it.size > 3 && it[0] == "a" && it[3] == "fork" && it[1].contains(authorHex) } - open fun replyTos(): List { - val oldStylePositional = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] } + 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) - - val newStyleReplyTos = listOfNotNull(newStyleReply, newStyleRoot) - - return if (newStyleReplyTos.isNotEmpty()) { - newStyleReplyTos - } else { - oldStylePositional - } + return listOfNotNull(newStyleReply, newStyleRoot) } - fun replyingTo(): HexKey? { + open fun unMarkedReplyTos(): List = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] } + + 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) @@ -79,7 +73,7 @@ open class BaseTextNoteEvent( return newStyleReply ?: newStyleRoot ?: oldStylePositional } - fun replyingToAddress(): ATag? { + 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]) } @@ -87,7 +81,7 @@ open class BaseTextNoteEvent( return newStyleReply ?: newStyleRoot ?: oldStylePositional } - fun replyingToAddressOrEvent(): String? { + open fun replyingToAddressOrEvent(): String? { val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && (it[0] == "e" || it[0] == "a") }?.get(1) val newStyleReply = tags.lastOrNull { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "reply" }?.get(1) val newStyleRoot = tags.lastOrNull { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "root" }?.get(1) @@ -190,21 +184,33 @@ open class BaseTextNoteEvent( } fun tagsWithoutCitations(): List { - val repliesTo = replyTos() + val certainRepliesTo = markedReplyTos() + val uncertainRepliesTo = unMarkedReplyTos() + val tagAddresses = taggedAddresses() .filter { it.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || it.kind != WikiNoteEvent.KIND) // removes forks from itself. }.map { it.toTag() } - if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() + + if (certainRepliesTo.isEmpty() && uncertainRepliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() val citations = findCitations() return if (citations.isEmpty()) { - repliesTo + tagAddresses + if (certainRepliesTo.isNotEmpty()) { + certainRepliesTo + tagAddresses + } else { + uncertainRepliesTo + tagAddresses + } } else { - repliesTo.filter { it !in citations } + if (certainRepliesTo.isNotEmpty()) { + certainRepliesTo + tagAddresses.filter { it !in citations } + } else { + // mix bag between `e` for replies and `e` for citations + uncertainRepliesTo.filter { it !in citations } + } } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomAuthorizationEvent.kt new file mode 100644 index 000000000..9b8d9aa1b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomAuthorizationEvent.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.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class BlossomAuthorizationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 24242 + + fun createGetAuth( + hash: HexKey, + alt: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomAuthorizationEvent) -> Unit, + ) = createAuth("get", hash, null, alt, signer, createdAt, onReady) + + fun createListAuth( + signer: NostrSigner, + alt: String, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomAuthorizationEvent) -> Unit, + ) = createAuth("list", null, null, alt, signer, createdAt, onReady) + + fun createDeleteAuth( + hash: HexKey, + alt: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomAuthorizationEvent) -> Unit, + ) = createAuth("delete", hash, null, alt, signer, createdAt, onReady) + + fun createUploadAuth( + hash: HexKey, + size: Long, + alt: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomAuthorizationEvent) -> Unit, + ) = createAuth("upload", hash, size, alt, signer, createdAt, onReady) + + private fun createAuth( + type: String, + hash: HexKey?, + fileSize: Long?, + alt: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomAuthorizationEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf("t", type), + arrayOf("expiration", TimeUtils.oneHourAhead().toString()), + fileSize?.let { arrayOf("size", it.toString()) }, + hash?.let { arrayOf("x", it) }, + ) + + signer.sign(createdAt, KIND, tags.toTypedArray(), alt, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomServersEvent.kt new file mode 100644 index 000000000..02b1bb384 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomServersEvent.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.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class BlossomServersEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun dTag() = FIXED_D_TAG + + fun servers(): List = + tags.mapNotNull { + if (it.size > 1 && it[0] == "server") { + it[1] + } else { + null + } + } + + companion object { + const val KIND = 10063 + const val FIXED_D_TAG = "" + const val ALT = "File servers used by the author" + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun createAddressTag(pubKey: HexKey): String = ATag.assembleATag(KIND, pubKey, FIXED_D_TAG) + + fun createTagArray(servers: List): Array> = + servers + .map { + arrayOf("server", it) + }.plusElement(arrayOf("alt", ALT)) + .toTypedArray() + + fun updateRelayList( + earlierVersion: BlossomServersEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomServersEvent) -> Unit, + ) { + val tags = + earlierVersion.tags + .filter { it[0] != "server" } + .plus( + relays.map { + arrayOf("server", it) + }, + ).toTypedArray() + + signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + } + + fun createFromScratch( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomServersEvent) -> Unit, + ) { + create(relays, signer, createdAt, onReady) + } + + fun create( + servers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomServersEvent) -> Unit, + ) { + signer.sign(createdAt, KIND, createTagArray(servers), "", onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt index 104a4aace..ca455cf00 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -40,10 +41,9 @@ class ChannelMessageEvent( 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 replyTos() = - tags - .filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() } - .mapNotNull { it.getOrNull(1) } + override fun markedReplyTos() = super.markedReplyTos().filter { it != channel() } + + override fun unMarkedReplyTos() = super.unMarkedReplyTos().filter { it != channel() } companion object { const val KIND = 42 @@ -59,8 +59,9 @@ class ChannelMessageEvent( createdAt: Long = TimeUtils.now(), markAsSensitive: Boolean, zapRaiserAmount: Long?, + directMentions: Set = emptySet(), geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, isDraft: Boolean, onReady: (ChannelMessageEvent) -> Unit, ) { @@ -68,8 +69,14 @@ class ChannelMessageEvent( mutableListOf( arrayOf("e", channel, "", "root"), ) - replyTos?.forEach { tags.add(arrayOf("e", it)) } 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(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) } @@ -78,12 +85,8 @@ class ChannelMessageEvent( } zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - Nip92MediaAttachments().convertFromFileHeader(it)?.let { - tags.add(it) - } - } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) } tags.add( arrayOf("alt", ALT), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEncryptedFileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEncryptedFileHeaderEvent.kt new file mode 100644 index 000000000..fd5dcabb7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEncryptedFileHeaderEvent.kt @@ -0,0 +1,189 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.Dimension +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.hexToByteArray +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.signers.NostrSigner +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) { + arrayOf("content-warning", "") + } 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/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index 0ca280e80..73925dda5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -37,7 +38,8 @@ class ChatMessageEvent( content: String, sig: HexKey, ) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - ChatroomKeyable { + ChatroomKeyable, + NIP17Group { /** Recipients intended to receive this conversation */ fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null } @@ -61,6 +63,8 @@ class ChatMessageEvent( return result } + override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() + override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet()) companion object { @@ -79,7 +83,7 @@ class ChatMessageEvent( geohash: String? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - nip94attachments: List? = null, + imetas: List? = null, isDraft: Boolean, onReady: (ChatMessageEvent) -> Unit, ) { @@ -96,12 +100,8 @@ class ChatMessageEvent( zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } subject?.let { tags.add(arrayOf("subject", it)) } - nip94attachments?.let { - it.forEach { - Nip92MediaAttachments().convertFromFileHeader(it)?.let { - tags.add(it) - } - } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) } // tags.add(arrayOf("alt", alt)) @@ -114,6 +114,10 @@ class ChatMessageEvent( } } +interface NIP17Group { + fun groupMembers(): Set +} + interface ChatroomKeyable { fun chatroomKey(toRemove: HexKey): ChatroomKey } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt index bcd9def65..5a1aadf6b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -112,7 +114,7 @@ class ClassifiedsEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), isDraft: Boolean, @@ -188,10 +190,8 @@ class ClassifiedsEvent( } zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) - } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) } tags.add(arrayOf("alt", ALT)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt new file mode 100644 index 000000000..2e8e4c509 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt @@ -0,0 +1,224 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.ETag +import com.vitorpamplona.quartz.encoders.EventHint +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments +import com.vitorpamplona.quartz.encoders.PTag +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers + +@Immutable +class CommentEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + 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) + + fun getRootScopes() = tags.filter { it.size > 1 && it[0] == "I" || it[0] == "A" || it[0] == "E" } + + fun getRootKinds() = tags.filter { it.size > 1 && it[0] == "K" } + + fun getDirectReplies() = tags.filter { it.size > 1 && it[0] == "i" || it[0] == "a" || it[0] == "e" } + + fun getDirectKinds() = tags.filter { it.size > 1 && it[0] == "k" } + + fun isGeohashTag(tag: Array) = tag.size > 1 && (tag[0] == "i" || tag[0] == "I") && tag[1].startsWith("geo:") + + private fun getGeoHashList() = tags.filter { isGeohashTag(it) } + + override fun hasGeohashes() = tags.any { isGeohashTag(it) } + + override fun geohashes() = getGeoHashList().map { it[1].drop(4).lowercase() } + + override fun getGeoHash(): String? = geohashes().maxByOrNull { it.length } + + override fun isTaggedGeoHash(hashtag: String) = tags.any { isGeohashTag(it) && it[1].endsWith(hashtag, true) } + + override 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 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) + + 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)) } + + override fun replyingToAddressOrEvent(): HexKey? = replyingToAddress()?.toTag() ?: replyingTo() + + companion object { + const val KIND = 1111 + + fun rootGeohashMipMap(geohash: String): Array> = + geohash.indices + .asSequence() + .map { arrayOf("I", "geo:" + geohash.substring(0, it + 1)) } + .toList() + .reversed() + .toTypedArray() + + fun firstReplyToEvent( + msg: String, + replyingTo: EventHint, + usersMentioned: Set = emptySet(), + addressesMentioned: Set = emptySet(), + eventsMentioned: Set = emptySet(), + imetas: 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>() + + 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, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady) + } + + fun replyComment( + msg: String, + replyingTo: EventHint, + usersMentioned: Set = emptySet(), + addressesMentioned: Set = emptySet(), + eventsMentioned: Set = emptySet(), + imetas: 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, 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, + 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, 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, + 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()) } + + findHashtags(msg).forEach { + val lowercaseTag = it.lowercase() + tags.add(arrayOf("t", it)) + if (it != lowercaseTag) { + tags.add(arrayOf("t", it.lowercase())) + } + } + + findURLs(msg).forEach { tags.add(arrayOf("r", it)) } + + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) + } + + 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/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index 360c21d17..cbdf74a31 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -85,8 +85,6 @@ class ContactListEvent( fun unverifiedFollowGeohashSet() = tags.filter { it.size > 1 && it[0] == "g" }.mapNotNull { it.getOrNull(1) } - fun unverifiedFollowAddressSet() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { it.getOrNull(1) } - fun follows() = tags.mapNotNull { try { @@ -141,21 +139,11 @@ class ContactListEvent( val tags = listOf(arrayOf("alt", ALT)) + followUsers.map { - if (it.relayUri != null) { - arrayOf("p", it.pubKeyHex, it.relayUri) - } else { - arrayOf("p", it.pubKeyHex) - } + listOfNotNull("p", it.pubKeyHex, it.relayUri).toTypedArray() } + followTags.map { arrayOf("t", it) } + followEvents.map { arrayOf("e", it) } + - followCommunities.map { - if (it.relay != null) { - arrayOf("a", it.toTag(), it.relay) - } else { - arrayOf("a", it.toTag()) - } - } + + followCommunities.map { it.toATagArray() } + followGeohashes.map { arrayOf("g", it) } return signer.sign(createdAt, KIND, tags.toTypedArray(), content) @@ -189,13 +177,7 @@ class ContactListEvent( } + followTags.map { arrayOf("t", it) } + followEvents.map { arrayOf("e", it) } + - followCommunities.map { - if (it.relay != null) { - arrayOf("a", it.toTag(), it.relay) - } else { - arrayOf("a", it.toTag()) - } - } + + followCommunities.map { it.toATagArray() } + followGeohashes.map { arrayOf("g", it) } return create( @@ -452,6 +434,7 @@ class UserMetadata { var website: String? = null var about: String? = null var bot: Boolean? = null + var pronouns: String? = null var nip05: String? = null var nip05Verified: Boolean = false @@ -482,6 +465,8 @@ class UserMetadata { fun profilePicture(): String? = picture fun cleanBlankNames() { + if (pronouns == "null") pronouns = null + if (picture?.isNotEmpty() == true) picture = picture?.trim() if (nip05?.isNotEmpty() == true) nip05 = nip05?.trim() if (displayName?.isNotEmpty() == true) displayName = displayName?.trim() @@ -489,6 +474,7 @@ class UserMetadata { if (username?.isNotEmpty() == true) username = username?.trim() if (lud06?.isNotEmpty() == true) lud06 = lud06?.trim() if (lud16?.isNotEmpty() == true) lud16 = lud16?.trim() + if (pronouns?.isNotEmpty() == true) pronouns = pronouns?.trim() if (banner?.isNotEmpty() == true) banner = banner?.trim() if (website?.isNotEmpty() == true) website = website?.trim() @@ -505,6 +491,7 @@ class UserMetadata { if (banner?.isBlank() == true) banner = null if (website?.isBlank() == true) website = null if (domain?.isBlank() == true) domain = null + if (pronouns?.isBlank() == true) pronouns = null } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt index d0c139705..3285ae1a4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt @@ -132,6 +132,17 @@ class DraftEvent( create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) } + fun create( + dTag: String, + originalNote: InteractiveStoryBaseEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tags = mutableListOf>() + create(dTag, originalNote, tags, signer, createdAt, onReady) + } + fun create( dTag: String, originalNote: LiveActivitiesChatMessageEvent, @@ -188,6 +199,18 @@ class DraftEvent( create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) } + fun create( + dTag: String, + originalNote: CommentEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tagsWithMarkers = originalNote.getRootScopes() + originalNote.getDirectReplies() + + create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + } + fun create( dTag: String, originalNote: TextNoteEvent, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 438d059b9..e78ac2beb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -33,7 +33,6 @@ import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.ser.std.StdSerializer -import com.fasterxml.jackson.module.kotlin.addDeserializer import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.ATag @@ -50,6 +49,8 @@ import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.utils.remove +import com.vitorpamplona.quartz.utils.startsWith import java.math.BigDecimal import java.security.MessageDigest @@ -280,7 +281,13 @@ open class Event( return PoWRank.getCommited(id, commitedPoW) } - override fun getGeoHash(): String? = tags.firstOrNull { it.size > 1 && it[0] == "g" }?.get(1)?.ifBlank { null } + override fun getGeoHash(): String? = + tags + .filter { it.size > 1 && it[0] == "g" } + .maxByOrNull { + it[1].length + }?.get(1) + ?.ifBlank { null } override fun getReward(): BigDecimal? = try { @@ -289,6 +296,8 @@ open class Event( null } + fun filterTags(startsWith: Array) = tags.remove(startsWith) + open fun toNIP19(): String = if (this is AddressableEvent) { ATag(kind, pubKey, dTag(), null).toNAddr() @@ -550,7 +559,7 @@ class HostStub( interface AddressableEvent { fun dTag(): String - fun address(): ATag + fun address(relayHint: String? = null): ATag fun addressTag(): String } @@ -568,7 +577,7 @@ open class BaseAddressableEvent( AddressableEvent { override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) /** * Creates the tag in a memory effecient way (without creating the ATag class @@ -582,3 +591,5 @@ data class ZapSplitSetup( val weight: Double, val isLnAddress: Boolean, ) + +interface RootScope diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 821fd396c..bbc731b65 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -20,15 +20,13 @@ */ package com.vitorpamplona.quartz.events +import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.nip46.NostrConnectEvent class EventFactory { companion object { - val additionalFactories = - mutableMapOf( - WikiNoteEvent.KIND to ::WikiNoteEvent, - ) + val factories: MutableMap>, String, HexKey) -> Event> = mutableMapOf() fun create( id: String, @@ -48,6 +46,8 @@ class EventFactory { BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) + BlossomServersEvent.KIND -> BlossomServersEvent(id, pubKey, createdAt, tags, content, sig) + BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) @@ -59,6 +59,20 @@ class EventFactory { ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) + ChatMessageEncryptedFileHeaderEvent.KIND -> { + if (id.isBlank()) { + ChatMessageEncryptedFileHeaderEvent( + Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey(), + pubKey, + createdAt, + tags, + content, + sig, + ) + } else { + ChatMessageEncryptedFileHeaderEvent(id, pubKey, createdAt, tags, content, sig) + } + } ChatMessageEvent.KIND -> { if (id.isBlank()) { ChatMessageEvent( @@ -75,6 +89,7 @@ class EventFactory { } ChatMessageRelayListEvent.KIND -> ChatMessageRelayListEvent(id, pubKey, createdAt, tags, content, sig) ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) + CommentEvent.KIND -> CommentEvent(id, pubKey, createdAt, tags, content, sig) CommunityDefinitionEvent.KIND -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) CommunityListEvent.KIND -> CommunityListEvent(id, pubKey, createdAt, tags, content, sig) CommunityPostApprovalEvent.KIND -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) @@ -98,6 +113,9 @@ class EventFactory { GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + InteractiveStoryPrologueEvent.KIND -> InteractiveStoryPrologueEvent(id, pubKey, createdAt, tags, content, sig) + InteractiveStorySceneEvent.KIND -> InteractiveStorySceneEvent(id, pubKey, createdAt, tags, content, sig) + InteractiveStoryReadingStateEvent.KIND -> InteractiveStoryReadingStateEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) @@ -117,12 +135,14 @@ class EventFactory { NIP90UserDiscoveryResponseEvent.KIND -> NIP90UserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) + PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) RecommendRelayEvent.KIND -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig) + RelationshipStatusEvent.KIND -> RelationshipStatusEvent(id, pubKey, createdAt, tags, content, sig) RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) @@ -139,7 +159,7 @@ class EventFactory { VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) else -> { - additionalFactories[kind]?.let { + factories[kind]?.let { return it(id, pubKey, createdAt, tags, content, sig) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt index a477f42ae..ef136101a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -38,8 +39,6 @@ class FileHeaderEvent( fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } - fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } - 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) @@ -48,7 +47,7 @@ class FileHeaderEvent( fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.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) @@ -76,6 +75,41 @@ class FileHeaderEvent( const val ORIGINAL_HASH = "ox" const val ALT = "alt" + fun buildTags( + 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) } ?: arrayOf("alt", 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) { + arrayOf("content-warning", "") + } else { + null + } + }, + ).toTypedArray() + fun create( url: String, magnetUri: String? = null, @@ -83,47 +117,20 @@ class FileHeaderEvent( alt: String? = null, hash: String? = null, size: String? = null, - dimensions: String? = null, + dimensions: Dimension? = null, blurhash: String? = null, originalHash: String? = null, magnetURI: String? = null, torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, sensitiveContent: Boolean? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (FileHeaderEvent) -> Unit, ) { - val tags = - listOfNotNull( - arrayOf(URL, url), - magnetUri?.let { arrayOf(MAGNET_URI, it) }, - mimeType?.let { arrayOf(MIME_TYPE, it) }, - alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), - hash?.let { arrayOf(HASH, it) }, - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - }, - ) + val tags = buildTags(url, magnetUri, mimeType, alt, hash, size, dimensions, blurhash, originalHash, magnetURI, torrentInfoHash, sensitiveContent) val content = alt ?: "" - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + signer.sign(createdAt, KIND, tags, content, onReady) } } } - -data class AESGCM( - val key: String, - val nonce: String, -) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt index d65506e65..e3adef3fb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt @@ -40,8 +40,6 @@ class FileStorageEvent( fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) - fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) } - fun decode(): ByteArray? = try { Base64.getDecoder().decode(content) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt index a3e85cdfa..2b3185b3a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -36,8 +37,6 @@ class FileStorageHeaderEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } - 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) @@ -46,7 +45,7 @@ class FileStorageHeaderEvent( fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.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) @@ -76,11 +75,10 @@ class FileStorageHeaderEvent( alt: String? = null, hash: String? = null, size: String? = null, - dimensions: String? = null, + dimensions: Dimension? = null, blurhash: String? = null, magnetURI: String? = null, torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, sensitiveContent: Boolean? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -93,11 +91,10 @@ class FileStorageHeaderEvent( hash?.let { arrayOf(HASH, it) }, alt?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, 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) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, sensitiveContent?.let { if (it) { arrayOf("content-warning", "") diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt index 76d09a40a..10f74e2ee 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt @@ -20,14 +20,10 @@ */ package com.vitorpamplona.quartz.events -import android.util.Log import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet import java.util.HashSet @@ -41,15 +37,7 @@ abstract class GeneralListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient private var privateTagsCache: Array>? = null - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0) - - override fun isContentEncoded() = true - +) : PrivateTagArrayEvent(id, pubKey, createdAt, kind, tags, content, sig) { fun category() = dTag() fun bookmarkedPosts() = taggedEvents() @@ -64,8 +52,6 @@ abstract class GeneralListEvent( fun nameOrTitle() = name()?.ifBlank { null } ?: title()?.ifBlank { null } - fun cachedPrivateTags(): Array>? = privateTagsCache - fun filterTagList( key: String, privateTags: Array>?, @@ -95,30 +81,6 @@ abstract class GeneralListEvent( onReady(isTagged(key, tag)) } - fun privateTags( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - if (content.isEmpty()) { - onReady(emptyArray()) - return - } - - privateTagsCache?.let { - onReady(it) - return - } - - try { - signer.decrypt(content, pubKey) { - privateTagsCache = mapper.readValue>>(it) - privateTagsCache?.let { onReady(it) } - } - } catch (e: Throwable) { - Log.w("GeneralList", "Error parsing the JSON ${e.message}") - } - } - fun privateTagsOrEmpty( signer: NostrSigner, onReady: (Array>) -> Unit, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt index 84bcc2b4e..5cd3334eb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt @@ -77,13 +77,7 @@ class GiftWrapEvent( onReady: (Event) -> Unit, ) { plainContent(signer) { giftStr -> - val gift = - try { - fromJson(giftStr) - } catch (e: Exception) { - Log.w("GiftWrapEvent", "Couldn't Parse the content " + this.toNostrUri() + " " + giftStr) - return@plainContent - } + val gift = fromJson(giftStr) if (gift is WrappedEvent) { gift.host = HostStub(this.id, this.pubKey, this.kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt index ed1236899..1757be562 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -90,7 +91,7 @@ class GitReplyEvent( root: String? = null, directMentions: Set = emptySet(), geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, forkedFrom: Event? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -148,12 +149,8 @@ class GitReplyEvent( } zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - Nip92MediaAttachments().convertFromFileHeader(it)?.let { - tags.add(it) - } - } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) } tags.add(arrayOf("alt", "a git issue reply")) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt new file mode 100644 index 000000000..bda994aed --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments + +open class InteractiveStoryBaseEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + fun title() = firstTag("title") + + fun summary() = firstTag("summary") + + fun image() = firstTag("image") + + 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, + ): Array> { + val tags = mutableListOf>() + findHashtags(content).forEach { + val lowercaseTag = it.lowercase() + tags.add(arrayOf("t", it)) + if (it != lowercaseTag) { + tags.add(arrayOf("t", it.lowercase())) + } + } + findURLs(content).forEach { tags.add(arrayOf("r", it)) } + + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) + } + 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) }, + arrayOf("alt", 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() + } +} + +class StoryOption( + val option: String, + val address: ATag, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt new file mode 100644 index 000000000..f75f19a6e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.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.events + +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +class InteractiveStoryPrologueEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : InteractiveStoryBaseEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { + companion object { + const val KIND = 30296 + const val ALT = "The prologue of an interative story called " + + fun createAddressATag( + pubKey: HexKey, + dtag: String, + ): ATag = ATag(KIND, pubKey, dtag, null) + + fun createAddressTag( + pubKey: HexKey, + dtag: String, + ): String = ATag.assembleATag(KIND, pubKey, dtag) + + fun create( + 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, + 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) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryReadingStateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryReadingStateEvent.kt new file mode 100644 index 000000000..0c0228554 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryReadingStateEvent.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.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers + +@Immutable +class InteractiveStoryReadingStateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = firstTag("title") + + fun summary() = firstTag("summary") + + fun image() = firstTag("image") + + fun status() = firstTag("status") + + fun root() = + tags.firstOrNull { it.size > 1 && it[0] == "A" }?.let { + ATag.parse(it[1], it.getOrNull(2)) + } + + fun currentScene() = + tags.firstOrNull { it.size > 1 && it[0] == "a" }?.let { + ATag.parse(it[1], it.getOrNull(2)) + } + + companion object { + const val KIND = 30298 + const val ALT1 = "Interactive Story Reading state" + const val ALT2 = "The reading state of " + + fun createAddressATag( + pubKey: HexKey, + dtag: String, + ): ATag = ATag(KIND, pubKey, dtag, null) + + fun createAddressTag( + pubKey: HexKey, + dtag: String, + ): String = ATag.assembleATag(KIND, pubKey, dtag) + + fun update( + base: InteractiveStoryReadingStateEvent, + currentScene: InteractiveStoryBaseEvent, + currentSceneRelay: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (InteractiveStoryReadingStateEvent) -> Unit, + ) { + val rootTag = base.dTag() + val sceneTag = currentScene.addressTag() + + val status = + if (rootTag == sceneTag) { + "new" + } else if (currentScene.options().isEmpty()) { + "done" + } else { + "reading" + } + + val tags = + base.tags.filter { it[0] != "a" && it[0] != "status" } + + listOf( + removeTrailingNullsAndEmptyOthers("a", sceneTag, currentSceneRelay), + arrayOf("status", status), + ) + + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } + + fun create( + 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() + val status = + if (rootTag == sceneTag) { + "new" + } else if (currentScene.options().isEmpty()) { + "done" + } else { + "reading" + } + + val tags = + listOfNotNull( + arrayOf("d", rootTag), + arrayOf("alt", 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() + + signer.sign(createdAt, KIND, tags, "", onReady) + } + } + + enum class ReadingStatus { + NEW, + READING, + DONE, + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt new file mode 100644 index 000000000..45807eeee --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.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.events + +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +class InteractiveStorySceneEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : InteractiveStoryBaseEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { + companion object { + const val KIND = 30297 + const val ALT = "A scene of an interative story called " + + fun createAddressATag( + pubKey: HexKey, + dtag: String, + ): ATag = ATag(KIND, pubKey, dtag, null) + + fun createAddressTag( + pubKey: HexKey, + dtag: String, + ): String = ATag.assembleATag(KIND, pubKey, dtag) + + fun create( + 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, + 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) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt index d32ad31af..e600a6b87 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -54,7 +55,9 @@ class LiveActivitiesChatMessageEvent( } } - override fun replyTos() = taggedEvents().minus(activityHex() ?: "") + override fun markedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") + + override fun unMarkedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") companion object { const val KIND = 1311 @@ -71,7 +74,7 @@ class LiveActivitiesChatMessageEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, isDraft: Boolean, onReady: (LiveActivitiesChatMessageEvent) -> Unit, ) { @@ -90,12 +93,8 @@ class LiveActivitiesChatMessageEvent( } zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - Nip92MediaAttachments().convertFromFileHeader(it)?.let { - tags.add(it) - } - } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) } tags.add(arrayOf("alt", ALT)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt index 7fbe65d57..b06909e27 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt @@ -38,7 +38,7 @@ class LongTextNoteEvent( AddressableEvent { override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) override fun addressTag() = ATag.assembleATag(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt index 6e68a9a3a..03c75f878 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt @@ -208,6 +208,7 @@ class MetadataEvent( nip05: String?, lnAddress: String?, lnURL: String?, + pronouns: String?, twitter: String?, mastodon: String?, github: String?, @@ -231,6 +232,7 @@ class MetadataEvent( 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()) } @@ -281,7 +283,7 @@ class MetadataEvent( key: String, value: String, ) { - if (value.isBlank()) { + if (value.isBlank() || value == "null") { currentJson.remove(key) } else { currentJson.put(key, value.trim()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt index 370df42b7..f3d8886c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.events +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.signers.NostrSigner class NIP17Factory { @@ -79,7 +81,7 @@ class NIP17Factory { markAsSensitive: Boolean = false, zapRaiserAmount: Long? = null, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String? = null, onReady: (Result) -> Unit, ) { @@ -97,7 +99,66 @@ class NIP17Factory { zapRaiserAmount = zapRaiserAmount, geohash = geohash, isDraft = draftTag != null, - nip94attachments = nip94attachments, + imetas = imetas, + ) { senderMessage -> + if (draftTag != null) { + onReady( + Result( + msg = senderMessage, + wraps = listOf(), + ), + ) + } 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, + 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) { onReady( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt new file mode 100644 index 000000000..97ac47063 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt @@ -0,0 +1,358 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.Dimension +import com.vitorpamplona.quartz.encoders.ETag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments.Companion.IMETA +import com.vitorpamplona.quartz.encoders.PTag +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PictureEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { + fun mimeTypes() = tags.filter { it.size > 1 && it[0] == MIME_TYPE } + + fun hashes() = tags.filter { it.size > 1 && it[0] == HASH } + + fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1) + + private fun url() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.URL }?.get(1) + + private fun urls() = tags.filter { it.size > 1 && it[0] == PictureMeta.URL }.map { it[1] } + + private fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.MIME_TYPE }?.get(1) + + private fun hash() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.HASH }?.get(1) + + private fun size() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.FILE_SIZE }?.get(1) + + private fun alt() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.ALT }?.get(1) + + private fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.DIMENSION }?.get(1)?.let { Dimension.parse(it) } + + private fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.BLUR_HASH }?.get(1) + + private fun hasUrl() = tags.any { it.size > 1 && it[0] == PictureMeta.URL } + + // hack to fix pablo's bug + fun rootImage() = + url()?.let { + PictureMeta( + url = it, + mimeType = mimeType(), + blurhash = blurhash(), + alt = alt(), + hash = hash(), + dimension = dimensions(), + size = size()?.toLongOrNull(), + fallback = emptyList(), + annotations = emptyList(), + ) + } + + fun imetaTags() = + tags + .map { tagArray -> + if (tagArray.size > 1 && tagArray[0] == IMETA) { + PictureMeta.parse(tagArray) + } else { + null + } + }.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, + 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) + } + + fun create( + 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, + createdAt: Long = TimeUtils.now(), + onReady: (PictureEvent) -> Unit, + ) { + val tags = mutableListOf(arrayOf("alt", ALT_DESCRIPTION)) + + 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) { + findHashtags(msg).forEach { + val lowercaseTag = it.lowercase() + tags.add(arrayOf("t", it)) + if (it != lowercaseTag) { + tags.add(arrayOf("t", it.lowercase())) + } + } + + findURLs(msg).forEach { tags.add(arrayOf("r", it)) } + } + + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + 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 + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt index 0e9039fab..5be47b30d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -79,7 +80,7 @@ class PollNoteEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, isDraft: Boolean, onReady: (PollNoteEvent) -> Unit, ) { @@ -104,12 +105,8 @@ class PollNoteEvent( } zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - Nip92MediaAttachments().convertFromFileHeader(it)?.let { - tags.add(it) - } - } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) } tags.add(arrayOf("alt", ALT)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt index b0584c8d4..cdaf38d46 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexValidator +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -124,16 +125,13 @@ class PrivateDmEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, isDraft: Boolean, onReady: (PrivateDmEvent) -> Unit, ) { var message = msg - nip94attachments?.forEach { - val myUrl = it.url() - if (myUrl != null) { - message = message.replace(myUrl, Nip54InlineMetadata().createUrl(myUrl, it.tags)) - } + imetas?.forEach { + message = message.replace(it.url, Nip54InlineMetadata().createUrl(it.url, it.properties)) } message = @@ -156,13 +154,10 @@ class PrivateDmEvent( zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } /* Privacy issue: DO NOT ADD THESE TO THE TAGS. - nip94attachments?.let { - it.forEach { - Nip92().convertFromFileHeader(it)?.let { - tags.add(it) - } - } - }*/ + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) + } + */ tags.add(arrayOf("alt", ALT)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateTagArrayEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateTagArrayEvent.kt new file mode 100644 index 000000000..37532e39a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateTagArrayEvent.kt @@ -0,0 +1,271 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.utils.remove +import com.vitorpamplona.quartz.utils.replaceAll + +@Immutable +abstract class PrivateTagArrayEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient private var privateTagsCache: Array>? = null + + override fun countMemory(): Long = + super.countMemory() + + pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0) + + override fun isContentEncoded() = true + + fun cachedPrivateTags(): Array>? = privateTagsCache + + fun privateTags( + signer: NostrSigner, + onReady: (Array>) -> Unit, + ) { + if (content.isEmpty()) { + onReady(emptyArray()) + return + } + + privateTagsCache?.let { + onReady(it) + return + } + + try { + signer.decrypt(content, pubKey) { + privateTagsCache = mapper.readValue>>(it) + privateTagsCache?.let { onReady(it) } + } + } catch (e: Throwable) { + Log.w("GeneralList", "Error parsing the JSON ${e.message}") + } + } + + fun decryptChangeEncrypt( + signer: NostrSigner, + change: (Array>) -> Array>, + onReady: (content: String) -> Unit, + ) { + privateTags(signer) { privateTags -> + encryptTags( + privateTags = change(privateTags), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags) + } + } + } + + companion object { + fun add( + current: PrivateTagArrayEvent, + newTag: Array, + toPrivate: Boolean, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + if (toPrivate) { + current.privateTags(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(newTag), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, current.tags) + } + } + } else { + onReady(current.content, current.tags.plus(newTag)) + } + } + + fun addAll( + current: PrivateTagArrayEvent, + newTag: Array>, + toPrivate: Boolean, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + if (toPrivate) { + current.privateTags(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(newTag), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, current.tags) + } + } + } else { + onReady(current.content, current.tags.plus(newTag)) + } + } + + fun replaceAllToPrivateNewTag( + dTag: String, + current: PrivateTagArrayEvent?, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + if (current == null) { + createPrivate(dTag, newTag, signer, onReady) + } else { + replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer, onReady) + } + } + + fun replaceAllToPublicNewTag( + dTag: String, + current: PrivateTagArrayEvent?, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + if (current == null) { + createPublic(dTag, newTag, signer, onReady) + } else { + replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer, onReady) + } + } + + fun replaceAllToPrivateNewTag( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + current.privateTags(signer) { privateTags -> + encryptTags( + privateTags = privateTags.replaceAll(oldTagStartsWith, newTag), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, current.tags.remove(oldTagStartsWith)) + } + } + } + + fun replaceAllToPublicNewTag( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + current.privateTags(signer) { privateTags -> + encryptTags( + privateTags = privateTags.remove(oldTagStartsWith), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag)) + } + } + } + + fun removeAllFromPrivate( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + current.privateTags(signer) { privateTags -> + encryptTags( + privateTags = privateTags.remove(oldTagStartsWith), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, current.tags) + } + } + } + + fun removeAllFromPublic( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) = onReady(current.content, current.tags.remove(oldTagStartsWith)) + + fun removeAll( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + current.privateTags(signer) { privateTags -> + encryptTags( + privateTags = privateTags.remove(oldTagStartsWith), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, current.tags.remove(oldTagStartsWith)) + } + } + } + + fun createPrivate( + dTag: String, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + encryptTags( + privateTags = arrayOf(newTag), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, arrayOf(arrayOf("d", dTag))) + } + } + + fun createPublic( + dTag: String, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + onReady("", arrayOf(arrayOf("d", dTag), newTag)) + } + + fun encryptTags( + privateTags: Array>? = null, + signer: NostrSigner, + onReady: (String) -> Unit, + ) = signer.nip04Encrypt( + if (privateTags.isNullOrEmpty()) "" else mapper.writeValueAsString(privateTags), + signer.pubKey, + onReady, + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt index a46d5a613..5024b5115 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -38,8 +39,6 @@ class ProfileGalleryEntryEvent( fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } - fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } - 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) @@ -48,7 +47,7 @@ class ProfileGalleryEntryEvent( fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.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) @@ -89,12 +88,11 @@ class ProfileGalleryEntryEvent( alt: String? = null, hash: String? = null, size: String? = null, - dimensions: String? = null, + dimensions: Dimension? = null, blurhash: String? = null, originalHash: String? = null, magnetURI: String? = null, torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, sensitiveContent: Boolean? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -112,12 +110,11 @@ class ProfileGalleryEntryEvent( alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), hash?.let { arrayOf(HASH, it) }, size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, 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) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, sensitiveContent?.let { if (it) { arrayOf("content-warning", "") diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelationshipStatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelationshipStatusEvent.kt new file mode 100644 index 000000000..6dd91de9d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelationshipStatusEvent.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class RelationshipStatusEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + 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 + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + + fun create( + targetUser: HexKey, + petname: String? = null, + summary: String? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (RelationshipStatusEvent) -> Unit, + ) { + val tags = mutableListOf>() + tags.add(arrayOf("d", targetUser)) + tags.add(arrayOf("alt", ALT)) + + val privateTags = mutableListOf>() + petname?.let { privateTags.add(arrayOf(PETNAME, it)) } + summary?.let { privateTags.add(arrayOf(SUMMARY, it)) } + + encryptTags(privateTags.toTypedArray(), signer) { content -> + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index 363cce07a..212307010 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -25,6 +25,7 @@ import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -56,7 +57,7 @@ class TextNoteEvent( root: String? = null, directMentions: Set = emptySet(), geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, forkedFrom: Event? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -122,12 +123,8 @@ class TextNoteEvent( } zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - Nip92MediaAttachments().convertFromFileHeader(it)?.let { - tags.add(it) - } - } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) } if (isDraft) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt index cf73407af..21b751b50 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -61,7 +62,7 @@ class TorrentCommentEvent( directMentions: Set = emptySet(), zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = null, forkedFrom: Event? = null, isDraft: Boolean, onReady: (TorrentCommentEvent) -> Unit, @@ -122,12 +123,8 @@ class TorrentCommentEvent( } zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - Nip92MediaAttachments().convertFromFileHeader(it)?.let { - tags.add(it) - } - } + imetas?.forEach { + tags.add(Nip92MediaAttachments.createTag(it)) } tags.add(arrayOf("alt", ALT)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt index 4f037e790..dab687808 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments.Companion.IMETA import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -34,53 +36,75 @@ abstract class VideoEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), + RootScope { + private fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) - fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + private fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } - fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + private 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) + private 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) + private fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + + private fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } + + private fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + + private fun image() = tags.filter { it.size > 1 && it[0] == IMAGE }.map { it[1] } + + private fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.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) - - 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 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 thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1) + fun duration() = tags.firstOrNull { it.size > 1 && it[0] == DURATION }?.get(1) fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } fun isOneOf(mimeTypes: Set) = tags.any { it.size > 1 && it[0] == FileHeaderEvent.MIME_TYPE && mimeTypes.contains(it[1]) } + // hack to fix pablo's bug + fun rootVideo() = + url()?.let { + VideoMeta( + url = it, + mimeType = mimeType(), + blurhash = blurhash(), + alt = alt(), + hash = hash(), + dimension = dimensions(), + size = size()?.toIntOrNull(), + service = null, + fallback = emptyList(), + image = image(), + ) + } + + fun imetaTags() = + tags + .map { tagArray -> + if (tagArray.size > 1 && tagArray[0] == IMETA) { + VideoMeta.parse(tagArray) + } else { + null + } + }.plus(rootVideo()) + .filterNotNull() + companion object { private const val URL = "url" - 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 ORIGINAL_HASH = "ox" private const val ALT = "alt" private const val TITLE = "title" - private const val PUBLISHED_AT = "published_at" private const val SUMMARY = "summary" private const val DURATION = "duration" private const val IMAGE = "image" @@ -88,49 +112,131 @@ abstract class VideoEvent( fun create( kind: Int, + dTag: String, url: String, - magnetUri: String? = null, mimeType: String? = null, alt: String? = null, hash: String? = null, - size: String? = null, - dimensions: String? = null, + size: Int? = null, + duration: Int? = null, + dimensions: Dimension? = null, blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, sensitiveContent: Boolean? = null, + service: String? = null, altDescription: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (T) -> Unit, ) { - val tags = - listOfNotNull( - arrayOf(URL, url), - magnetUri?.let { arrayOf(MAGNET_URI, it) }, - mimeType?.let { arrayOf(MIME_TYPE, it) }, - alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", altDescription), - hash?.let { arrayOf(HASH, it) }, - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - }, + val video = + VideoMeta( + url, + mimeType, + blurhash, + dimensions, + alt, + hash, + size, + service, + emptyList(), + emptyList(), ) + val tags = mutableListOf>() + + tags.add(arrayOf("d", dTag)) + tags.add(arrayOf(ALT, altDescription)) + if (sensitiveContent == true) { + tags.add(arrayOf("content-warning", "")) + } + duration?.let { tags.add(arrayOf(DURATION, "duration")) } + + tags.add(video.toIMetaArray()) + 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) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt index 7f8389f47..f9a5c38dd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID @Immutable class VideoHorizontalEvent( @@ -33,48 +35,45 @@ class VideoHorizontalEvent( tags: Array>, content: String, sig: HexKey, -) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { companion object { const val KIND = 34235 const val ALT_DESCRIPTION = "Horizontal Video" fun create( url: String, - magnetUri: String? = null, mimeType: String? = null, alt: String? = null, hash: String? = null, - size: String? = null, - dimensions: String? = null, + size: Int? = null, + duration: Int? = null, + dimensions: Dimension? = null, blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, sensitiveContent: Boolean? = null, + service: String? = null, + dTag: String = UUID.randomUUID().toString(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (VideoHorizontalEvent) -> Unit, ) { create( - KIND, - url, - magnetUri, - mimeType, - alt, - hash, - size, - dimensions, - blurhash, - originalHash, - magnetURI, - torrentInfoHash, - encryptionKey, - sensitiveContent, - ALT_DESCRIPTION, - signer, - createdAt, - onReady, + 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, ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt index 66e9cf2b5..5abc2a128 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID @Immutable class VideoVerticalEvent( @@ -33,48 +35,45 @@ class VideoVerticalEvent( tags: Array>, content: String, sig: HexKey, -) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { companion object { const val KIND = 34236 const val ALT_DESCRIPTION = "Vertical Video" fun create( url: String, - magnetUri: String? = null, mimeType: String? = null, alt: String? = null, hash: String? = null, - size: String? = null, - dimensions: String? = null, + size: Int? = null, + duration: Int? = null, + dimensions: Dimension? = null, blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, sensitiveContent: Boolean? = null, + service: String? = null, + dTag: String = UUID.randomUUID().toString(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (VideoVerticalEvent) -> Unit, ) { create( - KIND, - url, - magnetUri, - mimeType, - alt, - hash, - size, - dimensions, - blurhash, - originalHash, - magnetURI, - torrentInfoHash, - encryptionKey, - sensitiveContent, - ALT_DESCRIPTION, - signer, - createdAt, - onReady, + 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, ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt index f92d8730a..b6cc81eea 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt @@ -38,7 +38,7 @@ class WikiNoteEvent( AddressableEvent { override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) override fun addressTag() = ATag.assembleATag(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt new file mode 100644 index 000000000..3def1d85c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.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.utils + +public fun removeTrailingNullsAndEmptyOthers(vararg elements: String?): Array { + val lastNonNullIndex = elements.indexOfLast { it != null } + + if (lastNonNullIndex < 0) return Array(0) { "" } + + return Array(lastNonNullIndex + 1) { index -> + elements[index] ?: "" + } +} + +fun Array.startsWith(startsWith: Array): Boolean { + if (startsWith.size > this.size) return false + for (tagIdx in startsWith.indices) { + if (startsWith[tagIdx] != this[tagIdx]) return false + } + return true +} + +inline fun Array>.filterToArray(predicate: (Array) -> Boolean): Array> = filterTo(ArrayList(), predicate).toTypedArray() + +inline fun Array>.remove(predicate: (Array) -> Boolean): Array> = filterNotTo(ArrayList(this.size), predicate).toTypedArray() + +inline fun Array>.remove(startsWith: Array): Array> = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).toTypedArray() + +inline fun Array>.replaceAll( + startsWith: Array, + newElement: Array, +): Array> = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).plusElement(newElement).toTypedArray() diff --git a/zapstore.yaml b/zapstore.yaml index def23280a..47dabbb2e 100644 --- a/zapstore.yaml +++ b/zapstore.yaml @@ -1,6 +1,5 @@ amethyst: android: - identifier: com.vitorpamplona.amethyst name: Amethyst description: The all-in-one Nostr client license: MIT