diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0cfbfda2d1..6e6e2910b6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,9 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- + - name: Linter (gradle) + run: ./gradlew ktlintCheck + - name: Test (gradle) run: ./gradlew test --no-daemon diff --git a/.idea/misc.xml b/.idea/misc.xml index 4d86ddb47e..bdd92780c2 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + diff --git a/README.md b/README.md index 233343f0d8..522bb99392 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,12 @@ Build the app: ./gradlew connectedAndroidTest ``` +## Linting +```bash +./gradlew ktlintCheck +./gradlew ktlintFormat +``` + ## Installing on device ```bash ./gradlew installDebug @@ -121,6 +127,24 @@ openssl base64 < | tr -d '\n' | tee some_signing_key.j 7. Add your CHANGE LOG to the description of the new release 8. Download the `aab` file and upload it to the` PlayStore. +# Privacy on Relays & nostr +Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online. + +The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address. + +Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs. + +# DM Privacy # +While the content of direct messages (DMs) is only visible to you, and your DM nostr counterparty, everyone can see that and when you and your counterparty are DM-ing each other. + +# Visibility & Permanence of Your Content on nostr +## Information Visibility ## +Content that you share can be shared to other relays. +Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you. + +## Information Permanence ## +Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted. + # Contributing [Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome. diff --git a/app/build.gradle b/app/build.gradle index a8711ff5e6..32ab8260ab 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'org.jlleitschuh.gradle.ktlint' version "11.3.1" } android { @@ -159,9 +160,14 @@ dependencies { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' testImplementation 'junit:junit:4.13.2' + testImplementation "io.mockk:mockk:1.13.4" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version" -} \ No newline at end of file +} + +ktlint { + disabledRules.set(["no-wildcard-imports"]) +} diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/EventSigCheck.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/EventSigCheck.kt index 2b6adfc773..944e4239ca 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/EventSigCheck.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/EventSigCheck.kt @@ -10,15 +10,14 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class EventSigCheck { - val payload1 = "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nI’ll give you one final explanation to rule them all. First, let’s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays it’s 500, others 1000, some as high as 5000. Let’s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That won’t change if you have 20,000 followers or 100,000. You may get back a “different” 5000 each time, but you’ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesn’t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" + val payload1 = "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nI’ll give you one final explanation to rule them all. First, let’s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays it’s 500, others 1000, some as high as 5000. Let’s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That won’t change if you have 20,000 followers or 100,000. You may get back a “different” 5000 each time, but you’ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesn’t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" - @Test - fun testUnicode2028and2029ShouldNotBeEscaped() { - val msg = Event.gson.fromJson(payload1, JsonElement::class.java).asJsonArray - val event = Event.fromJson(msg[2], Client.lenient) + @Test + fun testUnicode2028and2029ShouldNotBeEscaped() { + val msg = Event.gson.fromJson(payload1, JsonElement::class.java).asJsonArray + val event = Event.fromJson(msg[2], Client.lenient) - // Should pass - event.checkSignature() - } - -} \ No newline at end of file + // Should pass + event.checkSignature() + } +} diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/TranslationsTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/TranslationsTest.kt index 49cbedde4b..3cf5820514 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/TranslationsTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/TranslationsTest.kt @@ -11,68 +11,70 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class TranslationsTest { - fun translatePT(text: String, translateTo: String): String? { - val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo) - return Tasks.await(task).result - } + fun translatePT(text: String, translateTo: String): String? { + val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo) + return Tasks.await(task).result + } - fun assertTranslate(expected: String, input: String, translateTo: String) { - assertEquals(null, expected, translatePT(input, translateTo)) - } + fun assertTranslate(expected: String, input: String, translateTo: String) { + assertEquals(null, expected, translatePT(input, translateTo)) + } - fun assertTranslateContains(expected: String, input: String, translateTo: String) { - val translated = translatePT(input, translateTo)!! - assertTrue("'$translated' does not contain '$expected'", translated.contains(expected)) - } + fun assertTranslateContains(expected: String, input: String, translateTo: String) { + val translated = translatePT(input, translateTo)!! + assertTrue("'$translated' does not contain '$expected'", translated.contains(expected)) + } - @Test - fun testTranslation() { - assertTranslate("Olá mundo", "Hello World", "pt") - } + @Test + fun testTranslation() { + assertTranslate("Olá mundo", "Hello World", "pt") + } - @Test - fun testTranslationName() { - assertTranslate("Olá Vitor, como você está?", "Hello Vitor, how are you doing?", "pt") - } + @Test + fun testTranslationName() { + assertTranslate("Olá Vitor, como você está?", "Hello Vitor, how are you doing?", "pt") + } - @Test - fun testTranslationTag() { - assertTranslate("Você já viu isso, #[0]", "Have you seen this, #[0]", "pt") - } + @Test + fun testTranslationTag() { + assertTranslate("Você já viu isso, #[0]", "Have you seen this, #[0]", "pt") + } - @Test - fun testTranslationUrl() { - assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt") - assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt") - assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt") - assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt") - assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt") - assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt") - assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt") - assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt") - assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt") - } + @Test + fun testTranslationUrl() { + assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt") + assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt") + assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt") + assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt") + assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt") + assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt") + assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt") + assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt") + assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt") + } - @Test - fun testChineseWithUrlDetector() { - assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端", "en") - } + @Test + fun testChineseWithUrlDetector() { + assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端", "en") + } - @Test - fun testTranslationEmail() { - assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt") - } + @Test + fun testTranslationEmail() { + assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt") + } - @Test - fun testTranslationLnInvoice() { - assertTranslateContains( - "lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn", - "Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", "pt" - ) + @Test + fun testTranslationLnInvoice() { + assertTranslateContains( + "lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn", + "Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", + "pt" + ) - assertTranslateContains( - "lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", - "Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", "pt" - ) - } -} \ No newline at end of file + assertTranslateContains( + "lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", + "Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", + "pt" + ) + } +} diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index 706bae2126..4f98b023ef 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -9,7 +9,7 @@ import com.vitorpamplona.amethyst.model.UserMetadata import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting -import org.junit.Assert.* +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -19,82 +19,81 @@ import org.junit.runner.RunWith * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) -class EUrlUserTagTransformationTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) - } +class UrlUserTagTransformationTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) + } - @Test - fun transformationText() { - val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey()) - user.info = UserMetadata() - user.info?.displayName = "Vitor Pamplona" + @Test + fun transformationText() { + val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey()) + user.info = UserMetadata() + user.info?.displayName = "Vitor Pamplona" - var transformedText = buildAnnotatedStringWithUrlHighlighting( - AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), - Color.Red - ) + val transformedText = buildAnnotatedStringWithUrlHighlighting( + AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), + Color.Red + ) - assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) + assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) - assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N - assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H - assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ - assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n - assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p - assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u - assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N + assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H + assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ + assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n + assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p + assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u + assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b + assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 - assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) - assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) + assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) + assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) + assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) + assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) - assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) - assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) - assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) - assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) + assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) + } - assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) - assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) - } + @Test + fun transformationTextTwoKeys() { + val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey()) + user.info = UserMetadata() + user.info?.displayName = "Vitor Pamplona" - @Test - fun transformationTextTwoKeys() { - val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey()) - user.info = UserMetadata() - user.info?.displayName = "Vitor Pamplona" + val transformedText = buildAnnotatedStringWithUrlHighlighting( + AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), + Color.Red + ) - var transformedText = buildAnnotatedStringWithUrlHighlighting( - AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), - Color.Red - ) + assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) - assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z + assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before + assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a + assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n + assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d + assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before + assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ + assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n - assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 - assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z - assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before - assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a - assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n - assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d - assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before - assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ - assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n - - assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a - assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before - assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a - assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n - assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d - assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before - assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ - } -} \ No newline at end of file + assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before + assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a + assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n + assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d + assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before + assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index 602e05a09f..9ab8a86842 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -6,17 +6,16 @@ import androidx.security.crypto.MasterKeys class EncryptedStorage { - fun preferences(context: Context): EncryptedSharedPreferences { - val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC); - val preferencesName = "secret_keeper" + fun preferences(context: Context): EncryptedSharedPreferences { + val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + val preferencesName = "secret_keeper" - return EncryptedSharedPreferences.create( - preferencesName, - secretKey, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) as EncryptedSharedPreferences - } - -} \ No newline at end of file + return EncryptedSharedPreferences.create( + preferencesName, + secretKey, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) as EncryptedSharedPreferences + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 950e24d603..abec38a633 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -6,12 +6,12 @@ import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.toByteArray -import java.util.Locale -import nostr.postr.Persona import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent +import nostr.postr.Persona import nostr.postr.toHex +import java.util.Locale class LocalPreferences(context: Context) { private object PrefKeys { @@ -25,7 +25,8 @@ class LocalPreferences(context: Context) { const val TRANSLATE_TO = "translateTo" const val ZAP_AMOUNTS = "zapAmounts" const val LATEST_CONTACT_LIST = "latestContactList" - val LAST_READ: (String) -> String = { route -> "last_read_route_${route}" } + const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo" + val LAST_READ: (String) -> String = { route -> "last_read_route_$route" } } private val encryptedPreferences = EncryptedStorage().preferences(context) @@ -49,6 +50,7 @@ class LocalPreferences(context: Context) { account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) } account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) } account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) } + putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo) }.apply() } @@ -83,12 +85,14 @@ class LocalPreferences(context: Context) { val languagePreferences = try { getString(PrefKeys.LANGUAGE_PREFS, null)?.let { gson.fromJson(it, object : TypeToken>() {}.type) as Map - } ?: mapOf() + } ?: mapOf() } catch (e: Throwable) { e.printStackTrace() - mapOf() + mapOf() } + val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false) + if (pubKey != null) { return Account( Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), @@ -99,6 +103,7 @@ class LocalPreferences(context: Context) { languagePreferences, translateTo, zapAmountChoices, + hideDeleteRequestInfo, latestContactList ) } else { @@ -118,5 +123,4 @@ class LocalPreferences(context: Context) { return getLong(PrefKeys.LAST_READ(route), 0) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt index 90018df67a..0173fe3bde 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt @@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst import android.content.Context import androidx.lifecycle.LiveData -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -10,60 +9,61 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean object NotificationCache { - val lastReadByRoute = mutableMapOf() + val lastReadByRoute = mutableMapOf() - fun markAsRead(route: String, timestampInSecs: Long, context: Context) { - val lastTime = lastReadByRoute[route] - if (lastTime == null || timestampInSecs > lastTime) { - lastReadByRoute.put(route, timestampInSecs) + fun markAsRead(route: String, timestampInSecs: Long, context: Context) { + val lastTime = lastReadByRoute[route] + if (lastTime == null || timestampInSecs > lastTime) { + lastReadByRoute.put(route, timestampInSecs) - val scope = CoroutineScope(Job() + Dispatchers.IO) - scope.launch { - LocalPreferences(context).saveLastRead(route, timestampInSecs) - live.invalidateData() - } - } - } - - fun load(route: String, context: Context): Long { - var lastTime = lastReadByRoute[route] - if (lastTime == null) { - lastTime = LocalPreferences(context).loadLastRead(route) - lastReadByRoute[route] = lastTime - } - return lastTime - } - - // Observers line up here. - val live: NotificationLiveData = NotificationLiveData(this) -} - -class NotificationLiveData(val cache: NotificationCache): LiveData(NotificationState(cache)) { - // Refreshes observers in batches. - var handlerWaiting = AtomicBoolean() - - fun invalidateData() { - if (!hasActiveObservers()) return - if (handlerWaiting.getAndSet(true)) return - - val scope = CoroutineScope(Job() + Dispatchers.Main) - scope.launch { - try { - delay(100) - refresh() - } finally { - withContext(NonCancellable) { - handlerWaiting.set(false) + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + LocalPreferences(context).saveLastRead(route, timestampInSecs) + live.invalidateData() + } } - } } - } - fun refresh() { - postValue(NotificationState(cache)) - } + fun load(route: String, context: Context): Long { + var lastTime = lastReadByRoute[route] + if (lastTime == null) { + lastTime = LocalPreferences(context).loadLastRead(route) + lastReadByRoute[route] = lastTime + } + return lastTime + } + + // Observers line up here. + val live: NotificationLiveData = NotificationLiveData(this) } -class NotificationState(val cache: NotificationCache) \ No newline at end of file +class NotificationLiveData(val cache: NotificationCache) : LiveData(NotificationState(cache)) { + // Refreshes observers in batches. + var handlerWaiting = AtomicBoolean() + + fun invalidateData() { + if (!hasActiveObservers()) return + if (handlerWaiting.getAndSet(true)) return + + val scope = CoroutineScope(Job() + Dispatchers.Main) + scope.launch { + try { + delay(100) + refresh() + } finally { + withContext(NonCancellable) { + handlerWaiting.set(false) + } + } + } + } + + fun refresh() { + postValue(NotificationState(cache)) + } +} + +class NotificationState(val cache: NotificationCache) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt index 2f23c5fbca..a4d95abd40 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt @@ -7,84 +7,82 @@ import android.graphics.Paint import android.util.LruCache import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap -import java.util.UUID import name.neuhalfen.projects.android.robohash.buckets.VariableSizeHashing import name.neuhalfen.projects.android.robohash.handle.Handle import name.neuhalfen.projects.android.robohash.handle.HandleFactory import name.neuhalfen.projects.android.robohash.paths.Configuration import name.neuhalfen.projects.android.robohash.repository.ImageRepository +import java.util.UUID object RoboHashCache { - lateinit var robots: MyRoboHash + lateinit var robots: MyRoboHash - lateinit var defaultAvatar: ImageBitmap + lateinit var defaultAvatar: ImageBitmap - @Synchronized - fun get(context: Context, hash: String): ImageBitmap { - if (!this::robots.isInitialized) { - robots = MyRoboHash(context) + @Synchronized + fun get(context: Context, hash: String): ImageBitmap { + if (!this::robots.isInitialized) { + robots = MyRoboHash(context) - defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap() + defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap() + } + + return defaultAvatar } - - return defaultAvatar - } - } - /** * Recreates RoboHash to use a custom configuration */ class MyRoboHash(context: Context) { - private val configuration: Configuration = ModifiedSet1Configuration() - private val repository: ImageRepository - private val hashing = VariableSizeHashing(configuration.bucketSizes) + private val configuration: Configuration = ModifiedSet1Configuration() + private val repository: ImageRepository + private val hashing = VariableSizeHashing(configuration.bucketSizes) - // Optional - private var memoryCache: LruCache? = null + // Optional + private var memoryCache: LruCache? = null - init { - repository = ImageRepository(context.assets) - } - - fun useCache(memoryCache: LruCache?) { - this.memoryCache = memoryCache - } - - fun calculateHandleFromUUID(uuid: UUID?): Handle { - val data = hashing.createBuckets(uuid) - return handleFactory.calculateHandle(data) - } - - fun imageForHandle(handle: Handle): Bitmap { - if (null != memoryCache) { - val cached = memoryCache!![handle.toString()] - if (null != cached) return cached + init { + repository = ImageRepository(context.assets) } - val bucketValues = handle.bucketValues() - val paths = configuration.convertToFacetParts(bucketValues) - val sampleSize = 1 - val buffer = repository.createBuffer(configuration.width(), configuration.height()) - val target = buffer.copy(Bitmap.Config.ARGB_8888, true) - val merged = Canvas(target) - val paint = Paint(0) - // The first image is not added as copy form the buffer - for (i in paths.indices) { - merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint) + fun useCache(memoryCache: LruCache?) { + this.memoryCache = memoryCache } - repository.returnBuffer(buffer) - if (null != memoryCache) { - memoryCache!!.put(handle.toString(), target) - } - return target - } - companion object { - private val handleFactory = HandleFactory() - } + fun calculateHandleFromUUID(uuid: UUID?): Handle { + val data = hashing.createBuckets(uuid) + return handleFactory.calculateHandle(data) + } + + fun imageForHandle(handle: Handle): Bitmap { + if (null != memoryCache) { + val cached = memoryCache!![handle.toString()] + if (null != cached) return cached + } + val bucketValues = handle.bucketValues() + val paths = configuration.convertToFacetParts(bucketValues) + val sampleSize = 1 + val buffer = repository.createBuffer(configuration.width(), configuration.height()) + val target = buffer.copy(Bitmap.Config.ARGB_8888, true) + val merged = Canvas(target) + val paint = Paint(0) + + // The first image is not added as copy form the buffer + for (i in paths.indices) { + merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint) + } + repository.returnBuffer(buffer) + if (null != memoryCache) { + memoryCache!!.put(handle.toString(), target) + } + return target + } + + companion object { + private val handleFactory = HandleFactory() + } } /** @@ -92,78 +90,78 @@ class MyRoboHash(context: Context) { * This uses the default location and ends up encoding number in the local language */ class ModifiedSet1Configuration : Configuration { - override fun convertToFacetParts(bucketValues: ByteArray): Array { - require(bucketValues.size == BUCKET_COUNT) - val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()] - val paths = mutableListOf() + override fun convertToFacetParts(bucketValues: ByteArray): Array { + require(bucketValues.size == BUCKET_COUNT) + val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()] + val paths = mutableListOf() - // e.g. - // blue face #2 - // blue nose #7 - // blue - val firstFacetBucket = BUCKET_COLOR + 1 - for (facet in 0 until FACET_COUNT) { - val bucketValue = bucketValues[firstFacetBucket + facet].toInt() - paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue)) + // e.g. + // blue face #2 + // blue nose #7 + // blue + val firstFacetBucket = BUCKET_COLOR + 1 + for (facet in 0 until FACET_COUNT) { + val bucketValue = bucketValues[firstFacetBucket + facet].toInt() + paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue)) + } + return paths.toTypedArray() } - return paths.toTypedArray() - } - private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String { - // TODO: Make more efficient - return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color) - .replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0')) - } + private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String { + // TODO: Make more efficient + return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color) + .replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0')) + } - override fun getBucketSizes(): ByteArray { - return BUCKET_SIZES - } + override fun getBucketSizes(): ByteArray { + return BUCKET_SIZES + } - override fun width(): Int { - return 300 - } + override fun width(): Int { + return 300 + } - override fun height(): Int { - return 300 - } + override fun height(): Int { + return 300 + } - companion object { - private const val ROOT = "sets/set1" - private const val BUCKET_COLOR = 0 - private const val COLOR_COUNT = 10 - private const val BODY_COUNT = 10 - private const val FACE_COUNT = 10 - private const val MOUTH_COUNT = 10 - private const val EYES_COUNT = 10 - private const val ACCESSORY_COUNT = 10 - private const val BUCKET_COUNT = 6 - private const val FACET_COUNT = 5 - private val BUCKET_SIZES = byteArrayOf( - COLOR_COUNT.toByte(), - BODY_COUNT.toByte(), - FACE_COUNT.toByte(), - MOUTH_COUNT.toByte(), - EYES_COUNT.toByte(), - ACCESSORY_COUNT.toByte() - ) - private val INT_TO_COLOR = arrayOf( - "blue", - "brown", - "green", - "grey", - "orange", - "pink", - "purple", - "red", - "white", - "yellow" - ) - private val FACET_PATH_TEMPLATES = arrayOf( - "#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png", - "#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png", - "#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png", - "#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png", - "#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png" - ) - } + companion object { + private const val ROOT = "sets/set1" + private const val BUCKET_COLOR = 0 + private const val COLOR_COUNT = 10 + private const val BODY_COUNT = 10 + private const val FACE_COUNT = 10 + private const val MOUTH_COUNT = 10 + private const val EYES_COUNT = 10 + private const val ACCESSORY_COUNT = 10 + private const val BUCKET_COUNT = 6 + private const val FACET_COUNT = 5 + private val BUCKET_SIZES = byteArrayOf( + COLOR_COUNT.toByte(), + BODY_COUNT.toByte(), + FACE_COUNT.toByte(), + MOUTH_COUNT.toByte(), + EYES_COUNT.toByte(), + ACCESSORY_COUNT.toByte() + ) + private val INT_TO_COLOR = arrayOf( + "blue", + "brown", + "green", + "grey", + "orange", + "pink", + "purple", + "red", + "white", + "yellow" + ) + private val FACET_PATH_TEMPLATES = arrayOf( + "#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png", + "#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png", + "#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png", + "#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png", + "#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png" + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index a0cb5de2f6..399c78f992 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -16,64 +16,62 @@ import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Constants object ServiceManager { - private var account: Account? = null + private var account: Account? = null - fun start(account: Account) { - this.account = account - start() - } - - fun start() { - val myAccount = account - - if (myAccount != null) { - Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays()) - - // start services - NostrAccountDataSource.account = myAccount - NostrHomeDataSource.account = myAccount - NostrChatroomListDataSource.account = myAccount - - // Notification Elements - NostrAccountDataSource.start() - NostrHomeDataSource.start() - NostrChatroomListDataSource.start() - - // More Info Data Sources - NostrSingleEventDataSource.start() - NostrSingleChannelDataSource.start() - NostrSingleUserDataSource.start() - } else { - // if not logged in yet, start a basic service wit default relays - Client.connect(Constants.convertDefaultRelays()) + fun start(account: Account) { + this.account = account + start() } - } - fun pause() { - NostrAccountDataSource.stop() - NostrHomeDataSource.stop() - NostrChannelDataSource.stop() - NostrChatroomListDataSource.stop() - NostrUserProfileDataSource.stop() + fun start() { + val myAccount = account - NostrGlobalDataSource.stop() - NostrSingleChannelDataSource.stop() - NostrSingleEventDataSource.stop() - NostrSingleUserDataSource.stop() - NostrThreadDataSource.stop() - NostrUserProfileDataSource.stop() + if (myAccount != null) { + Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays()) - Client.disconnect() - } + // start services + NostrAccountDataSource.account = myAccount + NostrHomeDataSource.account = myAccount + NostrChatroomListDataSource.account = myAccount - fun cleanUp() { - LocalCache.cleanObservers() + // Notification Elements + NostrAccountDataSource.start() + NostrHomeDataSource.start() + NostrChatroomListDataSource.start() - account?.let { - LocalCache.pruneOldAndHiddenMessages(it) - LocalCache.pruneHiddenMessages(it) - //LocalCache.pruneNonFollows(it) + // More Info Data Sources + NostrSingleEventDataSource.start() + NostrSingleChannelDataSource.start() + NostrSingleUserDataSource.start() + } else { + // if not logged in yet, start a basic service wit default relays + Client.connect(Constants.convertDefaultRelays()) + } } - } -} \ No newline at end of file + fun pause() { + NostrAccountDataSource.stop() + NostrHomeDataSource.stop() + NostrChannelDataSource.stop() + NostrChatroomListDataSource.stop() + + NostrGlobalDataSource.stop() + NostrSingleChannelDataSource.stop() + NostrSingleEventDataSource.stop() + NostrSingleUserDataSource.stop() + NostrThreadDataSource.stop() + NostrUserProfileDataSource.stop() + + Client.disconnect() + } + + fun cleanUp() { + LocalCache.cleanObservers() + + account?.let { + LocalCache.pruneOldAndHiddenMessages(it) + LocalCache.pruneHiddenMessages(it) + // LocalCache.pruneNonFollows(it) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt index fc341428f2..4d01c83019 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt @@ -9,33 +9,33 @@ import com.google.android.exoplayer2.upstream.cache.SimpleCache object VideoCache { - var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB + var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB - var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize) + var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize) - lateinit var exoDatabaseProvider: StandaloneDatabaseProvider - lateinit var simpleCache: SimpleCache + lateinit var exoDatabaseProvider: StandaloneDatabaseProvider + lateinit var simpleCache: SimpleCache - lateinit var cacheDataSourceFactory: CacheDataSource.Factory + lateinit var cacheDataSourceFactory: CacheDataSource.Factory - fun get(context: Context): CacheDataSource.Factory { - if (!this::simpleCache.isInitialized) { - exoDatabaseProvider = StandaloneDatabaseProvider(context) + fun get(context: Context): CacheDataSource.Factory { + if (!this::simpleCache.isInitialized) { + exoDatabaseProvider = StandaloneDatabaseProvider(context) - simpleCache = SimpleCache( - context.cacheDir, - leastRecentlyUsedCacheEvictor, - exoDatabaseProvider - ) + simpleCache = SimpleCache( + context.cacheDir, + leastRecentlyUsedCacheEvictor, + exoDatabaseProvider + ) - cacheDataSourceFactory = CacheDataSource.Factory() - .setCache(simpleCache) - .setUpstreamDataSourceFactory( - DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true) - ) - .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + cacheDataSourceFactory = CacheDataSource.Factory() + .setCache(simpleCache) + .setUpstreamDataSourceFactory( + DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true) + ) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + } + + return cacheDataSourceFactory } - - return cacheDataSourceFactory - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a199789aec..92a9a7f1cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -7,18 +7,20 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.Contact +import com.vitorpamplona.amethyst.service.model.ContactListEvent +import com.vitorpamplona.amethyst.service.model.DeletionEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.service.model.MetadataEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.RelayPool -import java.util.Date -import java.util.Locale -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -28,566 +30,580 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import nostr.postr.Persona -import com.vitorpamplona.amethyst.service.model.ContactListEvent -import com.vitorpamplona.amethyst.service.model.DeletionEvent -import com.vitorpamplona.amethyst.service.model.MetadataEvent -import com.vitorpamplona.amethyst.service.model.PrivateDmEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean val DefaultChannels = setOf( - "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr - "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group + "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group ) fun getLanguagesSpokenByUser(): Set { - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) - val codedList = mutableSetOf() - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { codedList.add(it.language) } - } - return codedList + val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) + val codedList = mutableSetOf() + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { codedList.add(it.language) } + } + return codedList } class Account( - val loggedIn: Persona, - var followingChannels: Set = DefaultChannels, - var hiddenUsers: Set = setOf(), - var localRelays: Set = Constants.defaultRelays.toSet(), - var dontTranslateFrom: Set = getLanguagesSpokenByUser(), - var languagePreferences: Map = mapOf(), - var translateTo: String = Locale.getDefault().language, - var zapAmountChoices: List = listOf(500L, 1000L, 5000L), - var backupContactList: ContactListEvent? = null + val loggedIn: Persona, + var followingChannels: Set = DefaultChannels, + var hiddenUsers: Set = setOf(), + var localRelays: Set = Constants.defaultRelays.toSet(), + var dontTranslateFrom: Set = getLanguagesSpokenByUser(), + var languagePreferences: Map = mapOf(), + var translateTo: String = Locale.getDefault().language, + var zapAmountChoices: List = listOf(500L, 1000L, 5000L), + var hideDeleteRequestInfo: Boolean = false, + var backupContactList: ContactListEvent? = null ) { - var transientHiddenUsers: Set = setOf() + var transientHiddenUsers: Set = setOf() - // Observers line up here. - val live: AccountLiveData = AccountLiveData(this) - val liveLanguages: AccountLiveData = AccountLiveData(this) - val saveable: AccountLiveData = AccountLiveData(this) + // Observers line up here. + val live: AccountLiveData = AccountLiveData(this) + val liveLanguages: AccountLiveData = AccountLiveData(this) + val saveable: AccountLiveData = AccountLiveData(this) - fun userProfile(): User { - return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) - } - - fun followingChannels(): List { - return followingChannels.map { LocalCache.getOrCreateChannel(it) } - } - - fun hiddenUsers(): List { - return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } - } - - fun isWriteable(): Boolean { - return loggedIn.privKey != null - } - - fun sendNewRelayList(relays: Map) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val follows = contactList?.follows() ?: emptyList() - - if (contactList != null && follows.isNotEmpty()) { - val event = ContactListEvent.create( - follows, - relays, - loggedIn.privKey!!) - - Client.send(event) - LocalCache.consume(event) - } else { - val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!) - - // Keep this local to avoid erasing a good contact list. - // Client.send(event) - LocalCache.consume(event) - } - } - - fun sendNewUserMetadata(toString: String) { - if (!isWriteable()) return - - loggedIn.privKey?.let { - val event = MetadataEvent.create(toString, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun reactionTo(note: Note): List { - return note.reactedBy(userProfile(), "+") - } - - fun hasBoosted(note: Note): Boolean { - return boostsTo(note).isNotEmpty() - } - - fun boostsTo(note: Note): List { - return note.boostedBy(userProfile()) - } - - fun hasReacted(note: Note): Boolean { - return note.hasReacted(userProfile(), "+") - } - - fun reactTo(note: Note) { - if (!isWriteable()) return - - if (hasReacted(note)) { - // has already liked this note - return + fun userProfile(): User { + return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) } - note.event?.let { - val event = ReactionEvent.createLike(it, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun createZapRequestFor(note: Note): LnZapRequestEvent? { - if (!isWriteable()) return null - - note.event?.let { - return LnZapRequestEvent.create(it, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!) + fun followingChannels(): List { + return followingChannels.map { LocalCache.getOrCreateChannel(it) } } - return null - } - - fun createZapRequestFor(user: User): LnZapRequestEvent? { - return createZapRequestFor(user.pubkeyHex) - } - - fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? { - if (!isWriteable()) return null - - return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!) - } - - fun report(note: Note, type: ReportEvent.ReportType) { - if (!isWriteable()) return - - if (note.hasReacted(userProfile(), "⚠️")) { - // has already liked this note - return + fun hiddenUsers(): List { + return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } } - note.event?.let { - val event = ReactionEvent.createWarning(it, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) + fun isWriteable(): Boolean { + return loggedIn.privKey != null } - note.event?.let { - val event = ReportEvent.create(it, type, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event, null) - } - } + fun sendNewRelayList(relays: Map) { + if (!isWriteable()) return - fun report(user: User, type: ReportEvent.ReportType) { - if (!isWriteable()) return + val contactList = userProfile().latestContactList + val follows = contactList?.follows() ?: emptyList() - if (user.hasReport(userProfile(), type)) { - // has already reported this note - return + if (contactList != null && follows.isNotEmpty()) { + val event = ContactListEvent.create( + follows, + relays, + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } else { + val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!) + + // Keep this local to avoid erasing a good contact list. + // Client.send(event) + LocalCache.consume(event) + } } - val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event, null) - } + fun sendNewUserMetadata(toString: String) { + if (!isWriteable()) return - fun delete(note: Note) { - delete(listOf(note)) - } - - fun delete(notes: List) { - if (!isWriteable()) return - - val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex } - - if (myNotes.isNotEmpty()) { - val event = DeletionEvent.create(myNotes, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun boost(note: Note) { - if (!isWriteable()) return - - if (note.hasBoostedInTheLast5Minutes(userProfile())) { - // has already bosted in the past 5mins - return + loggedIn.privKey?.let { + val event = MetadataEvent.create(toString, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } } - note.event?.let { - val event = RepostEvent.create(it, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun broadcast(note: Note) { - note.event?.let { - Client.send(it) - } - } - - fun follow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val follows = contactList?.follows() ?: emptyList() - - val event = if (contactList != null && follows.isNotEmpty()) { - ContactListEvent.create( - follows.plus(Contact(user.pubkeyHex, null)), - userProfile().relays, - loggedIn.privKey!!) - } else { - val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } - ContactListEvent.create( - listOf(Contact(user.pubkeyHex, null)), - relays, - loggedIn.privKey!! - ) + fun reactionTo(note: Note): List { + return note.reactedBy(userProfile(), "+") } - Client.send(event) - LocalCache.consume(event) - } - - fun unfollow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val follows = contactList?.follows() ?: emptyList() - - if (contactList != null && follows.isNotEmpty()) { - val event = ContactListEvent.create( - follows.filter { it.pubKeyHex != user.pubkeyHex }, - userProfile().relays, - loggedIn.privKey!!) - - Client.send(event) - LocalCache.consume(event) - } - } - - fun sendPost(message: String, replyTo: List?, mentions: List?) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - val signedEvent = TextNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - privateKey = loggedIn.privKey!! - ) - Client.send(signedEvent) - LocalCache.consume(signedEvent) - } - - fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List?) { - if (!isWriteable()) return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = mentions?.map { it.pubkeyHex } - - val signedEvent = ChannelMessageEvent.create( - message = message, - channel = toChannel, - replyTos = repliesToHex, - mentions = mentionsHex, - privateKey = loggedIn.privKey!! - ) - Client.send(signedEvent) - LocalCache.consume(signedEvent, null) - } - - fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) { - if (!isWriteable()) return - val user = LocalCache.users[toUser] ?: return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = emptyList() - - val signedEvent = PrivateDmEvent.create( - recipientPubKey = user.pubkey(), - publishedRecipientPubKey = user.pubkey(), - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - privateKey = loggedIn.privKey!!, - advertiseNip18 = false - ) - Client.send(signedEvent) - LocalCache.consume(signedEvent, null) - } - - fun sendCreateNewChannel(name: String, about: String, picture: String) { - if (!isWriteable()) return - - val metadata = ChannelCreateEvent.ChannelData( - name, about, picture - ) - - val event = ChannelCreateEvent.create( - channelInfo = metadata, - privateKey = loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - - joinChannel(event.id) - } - - fun joinChannel(idHex: String) { - followingChannels = followingChannels + idHex - live.invalidateData() - - saveable.invalidateData() - } - - fun leaveChannel(idHex: String) { - followingChannels = followingChannels - idHex - live.invalidateData() - - saveable.invalidateData() - } - - fun hideUser(pubkeyHex: String) { - hiddenUsers = hiddenUsers + pubkeyHex - live.invalidateData() - saveable.invalidateData() - } - - fun showUser(pubkeyHex: String) { - hiddenUsers = hiddenUsers - pubkeyHex - transientHiddenUsers = transientHiddenUsers - pubkeyHex - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapAmounts(newAmounts: List) { - zapAmountChoices = newAmounts - live.invalidateData() - saveable.invalidateData() - } - - fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { - if (!isWriteable()) return - - val metadata = ChannelCreateEvent.ChannelData( - name, about, picture - ) - - val event = ChannelMetadataEvent.create( - newChannelInfo = metadata, - originalChannelIdHex = channel.idHex, - privateKey = loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - - joinChannel(event.id) - } - - fun decryptContent(note: Note): String? { - val event = note.event - return if (event is PrivateDmEvent && loggedIn.privKey != null) { - var pubkeyToUse = event.pubKey - - val recepientPK = event.recipientPubKey() - - if (note.author == userProfile() && recepientPK != null) - pubkeyToUse = recepientPK - - event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) - } else { - event?.content - } - } - - fun addDontTranslateFrom(languageCode: String) { - dontTranslateFrom = dontTranslateFrom.plus(languageCode) - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun updateTranslateTo(languageCode: String) { - translateTo = languageCode - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun prefer(source: String, target: String, preference: String) { - languagePreferences = languagePreferences + Pair("$source,$target", preference) - saveable.invalidateData() - } - - fun preferenceBetween(source: String, target: String): String? { - return languagePreferences.get("$source,$target") - } - - private fun updateContactListTo(newContactList: ContactListEvent?) { - if (newContactList?.follows().isNullOrEmpty()) return - - // Events might be different objects, we have to compare their ids. - if (backupContactList?.id != newContactList?.id) { - backupContactList = newContactList - saveable.invalidateData() - } - } - - // Takes a User's relay list and adds the types of feeds they are active for. - fun activeRelays(): Array? { - var usersRelayList = userProfile().relays?.map { - val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet() - Relay(it.key, it.value.read, it.value.write, localFeedTypes) - } ?: return null - - // Ugly, but forces nostr.band as the only search-supporting relay today. - // TODO: Remove when search becomes more available. - if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) { - usersRelayList = usersRelayList + Relay( - Constants.forcedRelayForSearch.url, - Constants.forcedRelayForSearch.read, - Constants.forcedRelayForSearch.write, - Constants.forcedRelayForSearch.feedTypes - ) + fun hasBoosted(note: Note): Boolean { + return boostsTo(note).isNotEmpty() } - return usersRelayList.toTypedArray() - } - - fun convertLocalRelays(): Array { - return localRelays.map { - Relay(it.url, it.read, it.write, it.feedTypes) - }.toTypedArray() - } - - fun reconnectIfRelaysHaveChanged() { - val newRelaySet = activeRelays() ?: convertLocalRelays() - if (!Client.isSameRelaySetConfig(newRelaySet)) { - Client.disconnect() - Client.connect(newRelaySet) - RelayPool.requestAndWatch() - } - } - - fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers - - fun isAcceptable(user: User): Boolean { - return !isHidden(user) // if user hasn't hided this author - && user.reportsBy( userProfile() ).isEmpty() // if user has not reported this post - && user.countReportAuthorsBy( userProfile().follows ) < 5 - } - - fun isAcceptableDirect(note: Note): Boolean { - return note.reportsBy( userProfile() ).isEmpty() // if user has not reported this post - && note.countReportAuthorsBy( userProfile().follows ) < 5 // if it has 5 reports by reliable users - } - - fun isAcceptable(note: Note): Boolean { - return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author - && isAcceptableDirect(note) - && (note.event !is RepostEvent - || (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null) - ) // is not a reaction about a blocked post - } - - fun getRelevantReports(note: Note): Set { - val followsPlusMe = userProfile().follows + userProfile() - - val innerReports = if (note.event is RepostEvent) { - note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() - } else { - emptyList() + fun boostsTo(note: Note): List { + return note.boostedBy(userProfile()) } - return (note.reportsBy(followsPlusMe) + - (note.author?.reportsBy(followsPlusMe) ?: emptyList()) + - innerReports).toSet() - } - - fun saveRelayList(value: List) { - localRelays = value.toSet() - sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } ) - - saveable.invalidateData() - } - - init { - backupContactList?.let { - println("Loading saved contacts ${it.toJson()}") - if (userProfile().latestContactList == null) { - LocalCache.consume(it) - } + fun hasReacted(note: Note): Boolean { + return note.hasReacted(userProfile(), "+") } - // Observes relays to restart connections - userProfile().live().relays.observeForever { - GlobalScope.launch(Dispatchers.IO) { - reconnectIfRelaysHaveChanged() - } + fun reactTo(note: Note) { + if (!isWriteable()) return + + if (hasReacted(note)) { + // has already liked this note + return + } + + note.event?.let { + val event = ReactionEvent.createLike(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } } - // saves contact list for the next time. - userProfile().live().follows.observeForever { - updateContactListTo(userProfile().latestContactList) + fun createZapRequestFor(note: Note): LnZapRequestEvent? { + if (!isWriteable()) return null + + note.event?.let { + return LnZapRequestEvent.create(it, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!) + } + + return null } - // imports transient blocks due to spam. - LocalCache.antiSpam.liveSpam.observeForever { - GlobalScope.launch(Dispatchers.IO) { - it.cache.spamMessages.snapshot().values.forEach { - if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { - val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex) - if (userToBlock != userProfile() && userToBlock !in userProfile().follows) { - transientHiddenUsers = transientHiddenUsers + it.pubkeyHex + fun createZapRequestFor(user: User): LnZapRequestEvent? { + return createZapRequestFor(user.pubkeyHex) + } + + fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? { + if (!isWriteable()) return null + + return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!) + } + + fun report(note: Note, type: ReportEvent.ReportType) { + if (!isWriteable()) return + + if (note.hasReacted(userProfile(), "⚠️")) { + // has already liked this note + return + } + + note.event?.let { + val event = ReactionEvent.createWarning(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + + note.event?.let { + val event = ReportEvent.create(it, type, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event, null) + } + } + + fun report(user: User, type: ReportEvent.ReportType) { + if (!isWriteable()) return + + if (user.hasReport(userProfile(), type)) { + // has already reported this note + return + } + + val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event, null) + } + + fun delete(note: Note) { + delete(listOf(note)) + } + + fun delete(notes: List) { + if (!isWriteable()) return + + val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex } + + if (myNotes.isNotEmpty()) { + val event = DeletionEvent.create(myNotes, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun boost(note: Note) { + if (!isWriteable()) return + + if (note.hasBoostedInTheLast5Minutes(userProfile())) { + // has already bosted in the past 5mins + return + } + + note.event?.let { + val event = RepostEvent.create(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun broadcast(note: Note) { + note.event?.let { + Client.send(it) + } + } + + fun follow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val follows = contactList?.follows() ?: emptyList() + + val event = if (contactList != null && follows.isNotEmpty()) { + ContactListEvent.create( + follows.plus(Contact(user.pubkeyHex, null)), + userProfile().relays, + loggedIn.privKey!! + ) + } else { + val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } + ContactListEvent.create( + listOf(Contact(user.pubkeyHex, null)), + relays, + loggedIn.privKey!! + ) + } + + Client.send(event) + LocalCache.consume(event) + } + + fun unfollow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val follows = contactList?.follows() ?: emptyList() + + if (contactList != null && follows.isNotEmpty()) { + val event = ContactListEvent.create( + follows.filter { it.pubKeyHex != user.pubkeyHex }, + userProfile().relays, + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + } + + fun sendPost(message: String, replyTo: List?, mentions: List?) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + val signedEvent = TextNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + privateKey = loggedIn.privKey!! + ) + Client.send(signedEvent) + LocalCache.consume(signedEvent) + } + + fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List?) { + if (!isWriteable()) return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = mentions?.map { it.pubkeyHex } + + val signedEvent = ChannelMessageEvent.create( + message = message, + channel = toChannel, + replyTos = repliesToHex, + mentions = mentionsHex, + privateKey = loggedIn.privKey!! + ) + Client.send(signedEvent) + LocalCache.consume(signedEvent, null) + } + + fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) { + if (!isWriteable()) return + val user = LocalCache.users[toUser] ?: return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = emptyList() + + val signedEvent = PrivateDmEvent.create( + recipientPubKey = user.pubkey(), + publishedRecipientPubKey = user.pubkey(), + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + privateKey = loggedIn.privKey!!, + advertiseNip18 = false + ) + Client.send(signedEvent) + LocalCache.consume(signedEvent, null) + } + + fun sendCreateNewChannel(name: String, about: String, picture: String) { + if (!isWriteable()) return + + val metadata = ChannelCreateEvent.ChannelData( + name, + about, + picture + ) + + val event = ChannelCreateEvent.create( + channelInfo = metadata, + privateKey = loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + + joinChannel(event.id) + } + + fun joinChannel(idHex: String) { + followingChannels = followingChannels + idHex + live.invalidateData() + + saveable.invalidateData() + } + + fun leaveChannel(idHex: String) { + followingChannels = followingChannels - idHex + live.invalidateData() + + saveable.invalidateData() + } + + fun hideUser(pubkeyHex: String) { + hiddenUsers = hiddenUsers + pubkeyHex + live.invalidateData() + saveable.invalidateData() + } + + fun showUser(pubkeyHex: String) { + hiddenUsers = hiddenUsers - pubkeyHex + transientHiddenUsers = transientHiddenUsers - pubkeyHex + live.invalidateData() + saveable.invalidateData() + } + + fun changeZapAmounts(newAmounts: List) { + zapAmountChoices = newAmounts + live.invalidateData() + saveable.invalidateData() + } + + fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { + if (!isWriteable()) return + + val metadata = ChannelCreateEvent.ChannelData( + name, + about, + picture + ) + + val event = ChannelMetadataEvent.create( + newChannelInfo = metadata, + originalChannelIdHex = channel.idHex, + privateKey = loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + + joinChannel(event.id) + } + + fun decryptContent(note: Note): String? { + val event = note.event + return if (event is PrivateDmEvent && loggedIn.privKey != null) { + var pubkeyToUse = event.pubKey + + val recepientPK = event.recipientPubKey() + + if (note.author == userProfile() && recepientPK != null) { + pubkeyToUse = recepientPK + } + + event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) + } else { + event?.content() + } + } + + fun addDontTranslateFrom(languageCode: String) { + dontTranslateFrom = dontTranslateFrom.plus(languageCode) + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun updateTranslateTo(languageCode: String) { + translateTo = languageCode + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun prefer(source: String, target: String, preference: String) { + languagePreferences = languagePreferences + Pair("$source,$target", preference) + saveable.invalidateData() + } + + fun preferenceBetween(source: String, target: String): String? { + return languagePreferences.get("$source,$target") + } + + private fun updateContactListTo(newContactList: ContactListEvent?) { + if (newContactList?.follows().isNullOrEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupContactList?.id != newContactList?.id) { + backupContactList = newContactList + saveable.invalidateData() + } + } + + // Takes a User's relay list and adds the types of feeds they are active for. + fun activeRelays(): Array? { + var usersRelayList = userProfile().relays?.map { + val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet() + Relay(it.key, it.value.read, it.value.write, localFeedTypes) + } ?: return null + + // Ugly, but forces nostr.band as the only search-supporting relay today. + // TODO: Remove when search becomes more available. + if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) { + usersRelayList = usersRelayList + Relay( + Constants.forcedRelayForSearch.url, + Constants.forcedRelayForSearch.read, + Constants.forcedRelayForSearch.write, + Constants.forcedRelayForSearch.feedTypes + ) + } + + return usersRelayList.toTypedArray() + } + + fun convertLocalRelays(): Array { + return localRelays.map { + Relay(it.url, it.read, it.write, it.feedTypes) + }.toTypedArray() + } + + fun reconnectIfRelaysHaveChanged() { + val newRelaySet = activeRelays() ?: convertLocalRelays() + if (!Client.isSameRelaySetConfig(newRelaySet)) { + Client.disconnect() + Client.connect(newRelaySet) + RelayPool.requestAndWatch() + } + } + + fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers + + fun isAcceptable(user: User): Boolean { + return !isHidden(user) && // if user hasn't hided this author + user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post + user.countReportAuthorsBy(userProfile().follows) < 5 + } + + fun isAcceptableDirect(note: Note): Boolean { + return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post + note.countReportAuthorsBy(userProfile().follows) < 5 // if it has 5 reports by reliable users + } + + fun isAcceptable(note: Note): Boolean { + return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author + isAcceptableDirect(note) && + ( + note.event !is RepostEvent || + (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null) + ) // is not a reaction about a blocked post + } + + fun getRelevantReports(note: Note): Set { + val followsPlusMe = userProfile().follows + userProfile() + + val innerReports = if (note.event is RepostEvent) { + note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() + } else { + emptyList() + } + + return ( + note.reportsBy(followsPlusMe) + + (note.author?.reportsBy(followsPlusMe) ?: emptyList()) + + innerReports + ).toSet() + } + + fun saveRelayList(value: List) { + localRelays = value.toSet() + sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }) + + saveable.invalidateData() + } + + fun setHideDeleteRequestInfo() { + hideDeleteRequestInfo = true + saveable.invalidateData() + } + + init { + backupContactList?.let { + println("Loading saved contacts ${it.toJson()}") + if (userProfile().latestContactList == null) { + LocalCache.consume(it) + } + } + + // Observes relays to restart connections + userProfile().live().relays.observeForever { + GlobalScope.launch(Dispatchers.IO) { + reconnectIfRelaysHaveChanged() + } + } + + // saves contact list for the next time. + userProfile().live().follows.observeForever { + updateContactListTo(userProfile().latestContactList) + } + + // imports transient blocks due to spam. + LocalCache.antiSpam.liveSpam.observeForever { + GlobalScope.launch(Dispatchers.IO) { + it.cache.spamMessages.snapshot().values.forEach { + if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { + val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex) + if (userToBlock != userProfile() && userToBlock !in userProfile().follows) { + transientHiddenUsers = transientHiddenUsers + it.pubkeyHex + } + } + } } - } } - } } - } } -class AccountLiveData(private val account: Account): LiveData(AccountState(account)) { - var handlerWaiting = AtomicBoolean() +class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) { + var handlerWaiting = AtomicBoolean() - fun invalidateData() { - if (handlerWaiting.getAndSet(true)) return + fun invalidateData() { + if (handlerWaiting.getAndSet(true)) return - val scope = CoroutineScope(Job() + Dispatchers.Default) - scope.launch { - try { - delay(100) - refresh() - } finally { - withContext(NonCancellable) { - handlerWaiting.set(false) + val scope = CoroutineScope(Job() + Dispatchers.Default) + scope.launch { + try { + delay(100) + refresh() + } finally { + withContext(NonCancellable) { + handlerWaiting.set(false) + } + } } - } } - } - fun refresh() { - postValue(AccountState(account)) - } + fun refresh() { + postValue(AccountState(account)) + } } -class AccountState(val account: Account) \ No newline at end of file +class AccountState(val account: Account) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index a31a908e3a..dfed1554c8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.model import android.util.Log import android.util.LruCache import androidx.lifecycle.LiveData -import java.util.concurrent.atomic.AtomicBoolean +import com.vitorpamplona.amethyst.service.model.Event import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -11,80 +11,77 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.vitorpamplona.amethyst.service.model.Event +import java.util.concurrent.atomic.AtomicBoolean data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set) class AntiSpamFilter { - val recentMessages = LruCache(1000) - val spamMessages = LruCache(1000) + val recentMessages = LruCache(1000) + val spamMessages = LruCache(1000) - @Synchronized - fun isSpam(event: Event): Boolean { - val idHex = event.id + @Synchronized + fun isSpam(event: Event): Boolean { + val idHex = event.id - // if short message, ok - if (event.content.length < 50) return false + // if short message, ok + if (event.content.length < 50) return false - // double list strategy: - // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. + // double list strategy: + // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. - // Considers tags so that same replies to different people don't count. - val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() + // Considers tags so that same replies to different people don't count. + val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() - if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) { - Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}") + if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) { + Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}") - // Log down offenders - if (spamMessages.get(hash) == null) { - spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id))) - liveSpam.invalidateData() - } else { - val spammer = spamMessages.get(hash) - spammer.duplicatedMessages = spammer.duplicatedMessages + event.id + // Log down offenders + if (spamMessages.get(hash) == null) { + spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id))) + liveSpam.invalidateData() + } else { + val spammer = spamMessages.get(hash) + spammer.duplicatedMessages = spammer.duplicatedMessages + event.id - liveSpam.invalidateData() - } + liveSpam.invalidateData() + } - return true - } - - recentMessages.put(hash, idHex) - - return false - } - - val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this) -} - - -class AntiSpamLiveData(val cache: AntiSpamFilter): LiveData(AntiSpamState(cache)) { - - // Refreshes observers in batches. - var handlerWaiting = AtomicBoolean() - - fun invalidateData() { - if (!hasActiveObservers()) return - if (handlerWaiting.getAndSet(true)) return - - val scope = CoroutineScope(Job() + Dispatchers.Main) - scope.launch { - try { - delay(100) - refresh() - } finally { - withContext(NonCancellable) { - handlerWaiting.set(false) + return true } - } - } - } - private fun refresh() { - postValue(AntiSpamState(cache)) - } + recentMessages.put(hash, idHex) + + return false + } + + val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this) } -class AntiSpamState(val cache: AntiSpamFilter) { +class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData(AntiSpamState(cache)) { -} \ No newline at end of file + // Refreshes observers in batches. + var handlerWaiting = AtomicBoolean() + + fun invalidateData() { + if (!hasActiveObservers()) return + if (handlerWaiting.getAndSet(true)) return + + val scope = CoroutineScope(Job() + Dispatchers.Main) + scope.launch { + try { + delay(100) + refresh() + } finally { + withContext(NonCancellable) { + handlerWaiting.set(false) + } + } + } + } + + private fun refresh() { + postValue(AntiSpamState(cache)) + } +} + +class AntiSpamState(val cache: AntiSpamFilter) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 882b46efac..b5f23668ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -11,7 +11,7 @@ class Channel(val idHex: String) { var creator: User? = null var info = ChannelCreateEvent.ChannelData(null, null, null) - var updatedMetadataAt: Long = 0; + var updatedMetadataAt: Long = 0 val notes = ConcurrentHashMap() @@ -66,8 +66,7 @@ class Channel(val idHex: String) { } } - -class ChannelLiveData(val channel: Channel): LiveData(ChannelState(channel)) { +class ChannelLiveData(val channel: Channel) : LiveData(ChannelState(channel)) { fun refresh() { postValue(ChannelState(channel)) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt index c8fb14180f..c50ff57816 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.ui.note.toShortenHex import fr.acinq.secp256k1.Hex import nostr.postr.Bech32 import nostr.postr.Persona import nostr.postr.bechToBytes import nostr.postr.toHex +import nostr.postr.toNpub /** Makes the distinction between String and Hex **/ typealias HexKey = String @@ -14,70 +16,87 @@ typealias NPubKey = String typealias NoteId = String fun NPubKey.toDisplayKey(): String { - return this.toShortenHex() + return this.toShortenHex() } fun NoteId.toDisplayId(): String { - return this.toShortenHex() + return this.toShortenHex() } fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32) fun ByteArray.toHexKey(): HexKey { - return toHex() + return toHex() } fun HexKey.toByteArray(): ByteArray { - return Hex.decode(this) + return Hex.decode(this) } fun HexKey.toDisplayHexKey(): String { - return this.toShortenHex() + return this.toShortenHex() } fun decodePublicKey(key: String): ByteArray { - return if (key.startsWith("nsec")) { - Persona(privKey = key.bechToBytes()).pubKey - } else if (key.startsWith("npub")) { - key.bechToBytes() - } else if (key.startsWith("note")) { - key.bechToBytes() - } else { //if (pattern.matcher(key).matches()) { - //} else { - Hex.decode(key) - } + return if (key.startsWith("nsec")) { + Persona(privKey = key.bechToBytes()).pubKey + } else if (key.startsWith("npub")) { + key.bechToBytes() + } else if (key.startsWith("note")) { + key.bechToBytes() + } else { // if (pattern.matcher(key).matches()) { + // } else { + Hex.decode(key) + } } -data class DirtyKeyInfo(val type: String, val keyHex: String, val restOfWord: String) +data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String) fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { - var key = mightBeAKey - if (key.startsWith("nostr:", true)) { - key = key.substring("nostr:".length) - } - - key = key.removePrefix("@") - - if (key.length < 63) - return null - - try { - - val keyB32 = key.substring(0, 63) - val restOfWord = key.substring(63) - - if (key.startsWith("nsec1", true)) { - return DirtyKeyInfo("npub", Persona(privKey = keyB32.bechToBytes()).pubKey.toHexKey(), restOfWord) - } else if (key.startsWith("npub1", true)) { - return DirtyKeyInfo("npub", keyB32.bechToBytes().toHexKey(), restOfWord) - } else if (key.startsWith("note1", true)) { - return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord) + var key = mightBeAKey + if (key.startsWith("nostr:", true)) { + key = key.substring("nostr:".length) } + key = key.removePrefix("@") - } catch (e: Exception) { - e.printStackTrace() - } + if (key.length < 63) { + return null + } - return null -} \ No newline at end of file + try { + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + + if (key.startsWith("nsec1", true)) { + // Converts to npub + val pubkey = Nip19.uriToRoute(Persona(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null + + return DirtyKeyInfo(pubkey, restOfWord) + } else if (key.startsWith("npub1", true)) { + val pubkey = Nip19.uriToRoute(keyB32) ?: return null + + return DirtyKeyInfo(pubkey, restOfWord) + } else if (key.startsWith("note1", true)) { + val noteId = Nip19.uriToRoute(keyB32) ?: return null + + return DirtyKeyInfo(noteId, restOfWord) + } else if (key.startsWith("nprofile", true)) { + val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null + + return DirtyKeyInfo(pubkeyRelay, restOfWord) + } else if (key.startsWith("nevent", true)) { + val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null + + return DirtyKeyInfo(noteRelayId, restOfWord) + } else if (key.startsWith("naddr1", true)) { + val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null + + return DirtyKeyInfo(address, "") // no way to know when they address ends and dirt begins + } + } catch (e: Exception) { + e.printStackTrace() + } + + return null +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3591432208..57142e2dc2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -14,27 +14,21 @@ import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent -import com.vitorpamplona.amethyst.service.model.LnZapEvent -import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent -import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.model.ReactionEvent -import com.vitorpamplona.amethyst.service.model.ReportEvent -import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.DeletionEvent import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent +import com.vitorpamplona.amethyst.service.model.ReportEvent +import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.Relay import fr.acinq.secp256k1.Hex -import java.io.ByteArrayInputStream -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -43,891 +37,896 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import nostr.postr.toNpub - +import java.io.ByteArrayInputStream +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean object LocalCache { - val metadataParser = jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .readerFor(UserMetadata::class.java) - - val antiSpam = AntiSpamFilter() - - val users = ConcurrentHashMap() - val notes = ConcurrentHashMap() - val channels = ConcurrentHashMap() - val addressables = ConcurrentHashMap() - - fun checkGetOrCreateUser(key: String): User? { - return try { - val checkHex = Hex.decode(key).toNpub() // Checks if this is a valid Hex - getOrCreateUser(key) - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create user: $key", e) - null - } - } - - @Synchronized - fun getOrCreateUser(key: HexKey): User { - return users[key] ?: run { - val answer = User(key) - users.put(key, answer) - answer - } - } - - fun checkGetOrCreateNote(key: String): Note? { - if (key.startsWith("naddr1")) { - return checkGetOrCreateAddressableNote(key) - } - return try { - val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex - getOrCreateNote(key) - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create note: $key", e) - null - } - } - - @Synchronized - fun getOrCreateNote(idHex: String): Note { - return notes[idHex] ?: run { - val answer = Note(idHex) - notes.put(idHex, answer) - answer - } - } - - fun checkGetOrCreateChannel(key: String): Channel? { - return try { - val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex - getOrCreateChannel(key) - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create channel: $key", e) - null - } - } - - - @Synchronized - fun getOrCreateChannel(key: String): Channel { - return channels[key] ?: run { - val answer = Channel(key) - channels.put(key, answer) - answer - } - } - - fun checkGetOrCreateAddressableNote(key: String): AddressableNote? { - return try { - val addr = ATag.parse(key) - if (addr != null) - getOrCreateAddressableNote(addr) - else - null - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create channel: $key", e) - null - } - } - - @Synchronized - fun getOrCreateAddressableNote(key: ATag): AddressableNote { - return addressables[key.toNAddr()] ?: run { - val answer = AddressableNote(key) - answer.author = checkGetOrCreateUser(key.pubKeyHex) - addressables.put(key.toNAddr(), answer) - answer - } - } - - fun consume(event: MetadataEvent) { - // new event - val oldUser = getOrCreateUser(event.pubKey) - if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) { - val newUser = try { - metadataParser.readValue( - ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), - UserMetadata::class.java - ) - } catch (e: Exception) { - e.printStackTrace() - Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}") - return - } - - oldUser.updateUserInfo(newUser, event) - //Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") - } else { - //Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } - } - - fun formattedDateTime(timestamp: Long): String { - return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) - } - - - 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?.let { - it.spamCounter++ - } - return - } - - val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) } - val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, mentions, replyTo) - - //Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Prepares user's profile view. - author.addNote(note) - - // Adds notifications to users. - mentions.forEach { - it.addTaggedPost(note) - } - replyTo.forEach { - it.author?.addTaggedPost(note) - } - - // Counts the replies - replyTo.forEach { - it.addReply(note) - } - - refreshObservers() - } - - fun consume(event: LongTextNoteEvent, relay: Relay?) { - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event?.id == event.id) return - - if (antiSpam.isSpam(event)) { - relay?.let { - it.spamCounter++ - } - return - } - - val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) } - val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, mentions, replyTo) - - author.addNote(note) - - // Adds notifications to users. - mentions.forEach { - it.addTaggedPost(note) - } - replyTo.forEach { - it.author?.addTaggedPost(note) - } - - refreshObservers() - } - } - - fun consume(event: BadgeDefinitionEvent) { - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - // Already processed this event. - if (note.event?.id == event.id) return - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, emptyList(), emptyList()) - - refreshObservers() - } - } - - fun consume(event: BadgeProfilesEvent) { - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - // Already processed this event. - if (note.event?.id == event.id) return - - val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + - event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) } - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, emptyList(), replyTo) - - author.updateAcceptedBadges(note) - - refreshObservers() - } - } - - 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 awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) } - val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, awardees, awardDefinition) - - // Adds notifications to users. - awardees.forEach { - it.addTaggedPost(note) - } - - // Counts the replies - awardees.forEach { - it.addBadgeAward(note) - } - - // Replies of an Badge Definition are Award Events - awardDefinition.forEach { - it.addReply(note) - } - - refreshObservers() - } - - private fun findCitations(event: Event): Set { - var citations = mutableSetOf() - // Removes citations from replies: - val matcher = tagSearch.matcher(event.content) - while (matcher.find()) { - try { - val tag = matcher.group(1)?.let { event.tags[it.toInt()] } - if (tag != null && tag[0] == "e") { - citations.add(tag[1]) - } - } catch (e: Exception) { - - } - } - return citations - } - - private fun replyToWithoutCitations(event: TextNoteEvent): List { - val repliesTo = event.replyTos() - if (repliesTo.isEmpty()) return repliesTo - - val citations = findCitations(event) - - return if (citations.isEmpty()) { - repliesTo - } else { - repliesTo.filter { it !in citations } - } - } - - private fun replyToWithoutCitations(event: LongTextNoteEvent): List { - val repliesTo = event.replyTos() - if (repliesTo.isEmpty()) return repliesTo - - val citations = findCitations(event) - - return if (citations.isEmpty()) { - repliesTo - } else { - repliesTo.filter { it !in citations } - } - } - - fun consume(event: RecommendRelayEvent) { - //Log.d("RR", event.toJson()) - } - - fun consume(event: ContactListEvent) { - val user = getOrCreateUser(event.pubKey) - val follows = event.follows() - - if (event.createdAt > user.updatedFollowsAt && !follows.isNullOrEmpty()) { - // Saves relay list only if it's a user that is currently been seen - user.latestContactList = event - - user.updateFollows( - follows.map { - try { - val pubKey = decodePublicKey(it.pubKeyHex) - getOrCreateUser(pubKey.toHexKey()) - } catch (e: Exception) { - Log.w("ContactList Parser", "Ignoring: Could not parse Hex key: ${it.pubKeyHex} in ${event.toJson()}") - //e.printStackTrace() + val metadataParser = jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .readerFor(UserMetadata::class.java) + + val antiSpam = AntiSpamFilter() + + val users = ConcurrentHashMap() + val notes = ConcurrentHashMap() + val channels = ConcurrentHashMap() + val addressables = ConcurrentHashMap() + + fun checkGetOrCreateUser(key: String): User? { + return try { + val checkHex = Hex.decode(key).toNpub() // Checks if this is a valid Hex + getOrCreateUser(key) + } catch (e: IllegalArgumentException) { + Log.e("LocalCache", "Invalid Key to create user: $key", e) null - } - }.filterNotNull().toSet(), - event.createdAt - ) + } + } - // Saves relay list only if it's a user that is currently been seen - try { - if (event.content.isNotEmpty()) { - val relays: Map = - Event.gson.fromJson( - event.content, - object : TypeToken>() {}.type + @Synchronized + fun getOrCreateUser(key: HexKey): User { + return users[key] ?: run { + val answer = User(key) + users.put(key, answer) + answer + } + } + + fun checkGetOrCreateNote(key: String): Note? { + if (ATag.isATag(key)) { + return checkGetOrCreateAddressableNote(key) + } + return try { + val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex + getOrCreateNote(key) + } catch (e: IllegalArgumentException) { + Log.e("LocalCache", "Invalid Key to create note: $key", e) + null + } + } + + @Synchronized + fun getOrCreateNote(idHex: String): Note { + return notes[idHex] ?: run { + val answer = Note(idHex) + notes.put(idHex, answer) + answer + } + } + + fun checkGetOrCreateChannel(key: String): Channel? { + return try { + val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex + getOrCreateChannel(key) + } catch (e: IllegalArgumentException) { + Log.e("LocalCache", "Invalid Key to create channel: $key", e) + null + } + } + + @Synchronized + fun getOrCreateChannel(key: String): Channel { + return channels[key] ?: run { + val answer = Channel(key) + channels.put(key, answer) + answer + } + } + + fun checkGetOrCreateAddressableNote(key: String): AddressableNote? { + return try { + val addr = ATag.parse(key, null) // relay doesn't matter for the index. + if (addr != null) { + getOrCreateAddressableNote(addr) + } else { + null + } + } catch (e: IllegalArgumentException) { + Log.e("LocalCache", "Invalid Key to create channel: $key", e) + null + } + } + + @Synchronized + fun getOrCreateAddressableNote(key: ATag): AddressableNote { + // we can't use naddr here because naddr might include relay info and + // the preferred relay should not be part of the index. + return addressables[key.toTag()] ?: run { + val answer = AddressableNote(key) + answer.author = checkGetOrCreateUser(key.pubKeyHex) + addressables.put(key.toTag(), answer) + answer + } + } + + fun consume(event: MetadataEvent) { + // new event + val oldUser = getOrCreateUser(event.pubKey) + if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) { + val newUser = try { + metadataParser.readValue( + ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), + UserMetadata::class.java + ) + } catch (e: Exception) { + e.printStackTrace() + Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}") + return + } + + oldUser.updateUserInfo(newUser, event) + // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") + } else { + // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") + } + } + + fun formattedDateTime(timestamp: Long): String { + return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) + } + + 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?.let { + it.spamCounter++ + } + return + } + + val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) } + val replyTo = tagsWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, mentions, replyTo) + + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Prepares user's profile view. + author.addNote(note) + + // Adds notifications to users. + mentions.forEach { + it.addTaggedPost(note) + } + replyTo.forEach { + it.author?.addTaggedPost(note) + } + + // Counts the replies + replyTo.forEach { + it.addReply(note) + } + + refreshObservers() + } + + fun consume(event: LongTextNoteEvent, relay: Relay?) { + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event?.id() == event.id()) return + + if (antiSpam.isSpam(event)) { + relay?.let { + it.spamCounter++ + } + return + } + + val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) } + val replyTo = tagsWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, mentions, replyTo) + + author.addNote(note) + + // Adds notifications to users. + mentions.forEach { + it.addTaggedPost(note) + } + replyTo.forEach { + it.author?.addTaggedPost(note) + } + + refreshObservers() + } + } + + fun consume(event: BadgeDefinitionEvent) { + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event?.id() == event.id()) return + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList(), emptyList()) + + refreshObservers() + } + } + + fun consume(event: BadgeProfilesEvent) { + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event?.id() == event.id()) return + + val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + + event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) } + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList(), replyTo) + + author.updateAcceptedBadges(note) + + refreshObservers() + } + } + + 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 awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) } + val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, awardees, awardDefinition) + + // Adds notifications to users. + awardees.forEach { + it.addTaggedPost(note) + } + + // Counts the replies + awardees.forEach { + it.addBadgeAward(note) + } + + // Replies of an Badge Definition are Award Events + awardDefinition.forEach { + it.addReply(note) + } + + refreshObservers() + } + + private fun findCitations(event: Event): Set { + var citations = mutableSetOf() + // Removes citations from replies: + val matcher = tagSearch.matcher(event.content) + while (matcher.find()) { + try { + val tag = matcher.group(1)?.let { event.tags[it.toInt()] } + if (tag != null && tag[0] == "e") { + citations.add(tag[1]) + } + if (tag != null && tag[0] == "a") { + citations.add(tag[1]) + } + } catch (e: Exception) { + } + } + return citations + } + + private fun tagsWithoutCitations(event: TextNoteEvent): List { + val repliesTo = event.replyTos() + val tagAddresses = event.taggedAddresses().map { it.toTag() } + if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() + + val citations = findCitations(event) + + return if (citations.isEmpty()) { + repliesTo + tagAddresses + } else { + repliesTo.filter { it !in citations } + } + } + + private fun tagsWithoutCitations(event: LongTextNoteEvent): List { + val repliesTo = event.replyTos() + val tagAddresses = event.taggedAddresses().map { it.toTag() } + if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() + + val citations = findCitations(event) + + return if (citations.isEmpty()) { + repliesTo + tagAddresses + } else { + repliesTo.filter { it !in citations } + } + } + + fun consume(event: RecommendRelayEvent) { + // Log.d("RR", event.toJson()) + } + + fun consume(event: ContactListEvent) { + val user = getOrCreateUser(event.pubKey) + val follows = event.follows() + + if (event.createdAt > user.updatedFollowsAt && !follows.isNullOrEmpty()) { + // Saves relay list only if it's a user that is currently been seen + user.latestContactList = event + + user.updateFollows( + follows.map { + try { + val pubKey = decodePublicKey(it.pubKeyHex) + getOrCreateUser(pubKey.toHexKey()) + } catch (e: Exception) { + Log.w("ContactList Parser", "Ignoring: Could not parse Hex key: ${it.pubKeyHex} in ${event.toJson()}") + // e.printStackTrace() + null + } + }.filterNotNull().toSet(), + event.createdAt ) - user.updateRelays(relays) + // Saves relay list only if it's a user that is currently been seen + try { + if (event.content.isNotEmpty()) { + val relays: Map = + Event.gson.fromJson( + event.content, + object : TypeToken>() {}.type + ) + + user.updateRelays(relays) + } + } catch (e: Exception) { + Log.w("Relay List Parser", "Relay import issue ${e.message}", e) + e.printStackTrace() + } + + Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}") } - } catch (e: Exception) { - Log.w("Relay List Parser","Relay import issue ${e.message}", e) - e.printStackTrace() - } - - Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}") - } - } - - fun consume(event: PrivateDmEvent, 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 + fun consume(event: PrivateDmEvent, relay: Relay?) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) - val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) } - - //Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - - val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateNote(it) } - val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) } - - note.loadEvent(event, author, mentions, repliesTo) - - if (recipient != null) { - author.addMessage(recipient, note) - recipient.addMessage(author, note) - } - - refreshObservers() - } - - fun consume(event: DeletionEvent) { - var deletedAtLeastOne = false - - event.deleteEvents().mapNotNull { notes[it] }.forEach { deleteNote -> - // must be the same author - if (deleteNote.author?.pubkeyHex == event.pubKey) { - deleteNote.author?.removeNote(deleteNote) - - // reverts the add - deleteNote.mentions?.forEach { user -> - user.removeTaggedPost(deleteNote) - user.removeReport(deleteNote) + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - deleteNote.replyTo?.forEach { replyingNote -> - replyingNote.author?.removeTaggedPost(deleteNote) + // Already processed this event. + if (note.event != null) return + + val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) } + + // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") + + val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateNote(it) } + val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) } + + note.loadEvent(event, author, mentions, repliesTo) + + if (recipient != null) { + author.addMessage(recipient, note) + recipient.addMessage(author, note) + } + + refreshObservers() + } + + fun consume(event: DeletionEvent) { + var deletedAtLeastOne = false + + event.deleteEvents().mapNotNull { notes[it] }.forEach { deleteNote -> + // must be the same author + if (deleteNote.author?.pubkeyHex == event.pubKey) { + deleteNote.author?.removeNote(deleteNote) + + // reverts the add + deleteNote.mentions?.forEach { user -> + user.removeTaggedPost(deleteNote) + user.removeReport(deleteNote) + } + + deleteNote.replyTo?.forEach { replyingNote -> + replyingNote.author?.removeTaggedPost(deleteNote) + } + + // Counts the replies + deleteNote.replyTo?.forEach { masterNote -> + masterNote.removeReply(deleteNote) + masterNote.removeBoost(deleteNote) + masterNote.removeReaction(deleteNote) + masterNote.removeZap(deleteNote) + masterNote.removeReport(deleteNote) + } + + notes.remove(deleteNote.idHex) + + deletedAtLeastOne = true + } + } + + if (deletedAtLeastOne) { + live.invalidateData() + } + } + + fun consume(event: RepostEvent) { + 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 mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, mentions, repliesTo) + + // Prepares user's profile view. + author.addNote(note) + + // Adds notifications to users. + mentions.forEach { + it.addTaggedPost(note) + } + repliesTo.forEach { + it.author?.addTaggedPost(note) } // Counts the replies - deleteNote.replyTo?.forEach { masterNote -> - masterNote.removeReply(deleteNote) - masterNote.removeBoost(deleteNote) - masterNote.removeReaction(deleteNote) - masterNote.removeZap(deleteNote) - masterNote.removeReport(deleteNote) + repliesTo.forEach { + it.addBoost(note) } - notes.remove(deleteNote.idHex) - - deletedAtLeastOne = true - } - } - - if (deletedAtLeastOne) { - live.invalidateData() - } - } - - fun consume(event: RepostEvent) { - 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 mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, mentions, repliesTo) - - // Prepares user's profile view. - author.addNote(note) - - // Adds notifications to users. - mentions.forEach { - it.addTaggedPost(note) - } - repliesTo.forEach { - it.author?.addTaggedPost(note) - } - - // Counts the replies - repliesTo.forEach { - it.addBoost(note) - } - - refreshObservers() - } - - fun consume(event: ReactionEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - val author = getOrCreateUser(event.pubKey) - val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, mentions, repliesTo) - - //Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - // Adds notifications to users. - mentions.forEach { - it.addTaggedPost(note) - } - repliesTo.forEach { - it.author?.addTaggedPost(note) - } - - if ( - event.content == "" || - event.content == "+" || - event.content == "\u2764\uFE0F" || // red heart - event.content == "\uD83E\uDD19" || // call me hand - event.content == "\uD83D\uDC4D" // thumbs up - ) { - // Counts the replies - repliesTo.forEach { - it.addReaction(note) - } - } - - if (event.content == "!" // nostr_console hide. - || event.content == "\u26A0\uFE0F" // Warning sign - ) { - // Counts the replies - repliesTo.forEach { - it.addReport(note) - } - } - } - - fun consume(event: ReportEvent, 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 mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) } - val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } + - event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, mentions, repliesTo) - - //Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - // Adds notifications to users. - if (repliesTo.isEmpty()) { - mentions.forEach { - it.addReport(note) - } - } - repliesTo.forEach { - it.addReport(note) - } - } - - fun consume(event: ChannelCreateEvent) { - //Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") - // new event - val oldChannel = getOrCreateChannel(event.id) - val author = getOrCreateUser(event.pubKey) - if (event.createdAt > oldChannel.updatedMetadataAt) { - if (oldChannel.creator == null || oldChannel.creator == author) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - - val note = getOrCreateNote(event.id) - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList(), emptyList()) - refreshObservers() - } - } else { - // older data, does nothing } - } - - fun consume(event: ChannelMetadataEvent) { - val channelId = event.channel() - //Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}") - if (channelId.isNullOrBlank()) return - - // new event - val oldChannel = checkGetOrCreateChannel(channelId) ?: return - val author = getOrCreateUser(event.pubKey) - if (event.createdAt > oldChannel.updatedMetadataAt) { - if (oldChannel.creator == null || oldChannel.creator == author) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + fun consume(event: ReactionEvent) { val note = getOrCreateNote(event.id) - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList(), emptyList()) - refreshObservers() - } - } else { - //Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } - } + // Already processed this event. + if (note.event != null) return - fun consume(event: ChannelMessageEvent, relay: Relay?) { - val channelId = event.channel() + val author = getOrCreateUser(event.pubKey) + val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } - if (channelId.isNullOrBlank()) return + note.loadEvent(event, author, mentions, repliesTo) - val channel = checkGetOrCreateChannel(channelId) ?: return + // Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - val note = getOrCreateNote(event.id) - channel.addNote(note) - - 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?.let { - it.spamCounter++ - } - return - } - - val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) } - val replyTo = event.replyTos() - .mapNotNull { checkGetOrCreateNote(it) } - .filter { it.event !is ChannelCreateEvent } - - note.loadEvent(event, author, mentions, replyTo) - - //Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}") - - // Adds notifications to users. - mentions.forEach { - it.addTaggedPost(note) - } - replyTo.forEach { - it.author?.addTaggedPost(note) - } - - // Counts the replies - replyTo.forEach { - it.addReply(note) - } - - refreshObservers() - } - - fun consume(event: ChannelHideMessageEvent) { - - } - - fun consume(event: ChannelMuteUserEvent) { - - } - - fun consume(event: LnZapEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) } - - val author = getOrCreateUser(event.pubKey) - val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + - ((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet()) - - note.loadEvent(event, author, mentions, repliesTo) - - if (zapRequest == null) { - Log.e("ZP","Zap Request not found. Unable to process Zap {${event.toJson()}}") - return - } - - //Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - // Adds notifications to users. - mentions.forEach { - it.addTaggedPost(note) - } - repliesTo.forEach { - it.author?.addTaggedPost(note) - } - - repliesTo.forEach { - it.addZap(zapRequest, note) - } - mentions.forEach { - it.addZap(zapRequest, note) - } - } - - fun consume(event: LnZapRequestEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - val author = getOrCreateUser(event.pubKey) - val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, mentions, repliesTo) - - //Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - // Adds notifications to users. - mentions.forEach { - it.addTaggedPost(note) - } - repliesTo.forEach { - it.author?.addTaggedPost(note) - } - - repliesTo.forEach { - it.addZap(note, null) - } - mentions.forEach { - it.addZap(note, null) - } - } - - fun findUsersStartingWith(username: String): List { - return users.values.filter { - (it.anyNameStartsWith(username)) - || it.pubkeyHex.startsWith(username, true) - || it.pubkeyNpub().startsWith(username, true) - } - } - - fun findNotesStartingWith(text: String): List { - return notes.values.filter { - (it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false) - || (it.event is ChannelMessageEvent && it.event?.content?.contains(text, true) ?: false) - || it.idHex.startsWith(text, true) - || it.idNote().startsWith(text, true) - } + addressables.values.filter { - (it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false - || (it.event as? LongTextNoteEvent)?.title()?.contains(text, true) ?: false - || (it.event as? LongTextNoteEvent)?.summary()?.contains(text, true) ?: false - || it.idHex.startsWith(text, true) - } - } - - fun findChannelsStartingWith(text: String): List { - return channels.values.filter { - it.anyNameStartsWith(text) - || it.idHex.startsWith(text, true) - || it.idNote().startsWith(text, true) - } - } - - fun cleanObservers() { - notes.forEach { - it.value.clearLive() - } - - users.forEach { - it.value.clearLive() - } - } - - fun pruneOldAndHiddenMessages(account: Account) { - channels.forEach { - val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) - - toBeRemoved.forEach { - notes.remove(it.idHex) - // Doesn't need to clean up the replies and mentions.. Too small to matter. - - // reverts the add - it.mentions?.forEach { user -> - user.removeTaggedPost(it) + // Adds notifications to users. + mentions.forEach { + it.addTaggedPost(note) } - it.replyTo?.forEach { replyingNote -> - replyingNote.author?.removeTaggedPost(it) + repliesTo.forEach { + it.author?.addTaggedPost(note) + } + + if ( + event.content == "" || + event.content == "+" || + event.content == "\u2764\uFE0F" || // red heart + event.content == "\uD83E\uDD19" || // call me hand + event.content == "\uD83D\uDC4D" // thumbs up + ) { + // Counts the replies + repliesTo.forEach { + it.addReaction(note) + } + } + + if (event.content == "!" || // nostr_console hide. + event.content == "\u26A0\uFE0F" // Warning sign + ) { + // Counts the replies + repliesTo.forEach { + it.addReport(note) + } + } + } + + fun consume(event: ReportEvent, 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 mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) } + val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, mentions, repliesTo) + + // Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + // Adds notifications to users. + if (repliesTo.isEmpty()) { + mentions.forEach { + it.addReport(note) + } + } + repliesTo.forEach { + it.addReport(note) + } + } + + fun consume(event: ChannelCreateEvent) { + // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") + // new event + val oldChannel = getOrCreateChannel(event.id) + val author = getOrCreateUser(event.pubKey) + if (event.createdAt > oldChannel.updatedMetadataAt) { + if (oldChannel.creator == null || oldChannel.creator == author) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + + val note = getOrCreateNote(event.id) + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList(), emptyList()) + + refreshObservers() + } + } else { + // older data, does nothing + } + } + + fun consume(event: ChannelMetadataEvent) { + val channelId = event.channel() + // Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}") + if (channelId.isNullOrBlank()) return + + // new event + val oldChannel = checkGetOrCreateChannel(channelId) ?: return + val author = getOrCreateUser(event.pubKey) + if (event.createdAt > oldChannel.updatedMetadataAt) { + if (oldChannel.creator == null || oldChannel.creator == author) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + + val note = getOrCreateNote(event.id) + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList(), emptyList()) + + refreshObservers() + } + } else { + // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") + } + } + + fun consume(event: ChannelMessageEvent, relay: Relay?) { + val channelId = event.channel() + + if (channelId.isNullOrBlank()) return + + val channel = checkGetOrCreateChannel(channelId) ?: return + + val note = getOrCreateNote(event.id) + channel.addNote(note) + + 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?.let { + it.spamCounter++ + } + return + } + + val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) } + val replyTo = event.replyTos() + .mapNotNull { checkGetOrCreateNote(it) } + .filter { it.event !is ChannelCreateEvent } + + note.loadEvent(event, author, mentions, replyTo) + + // Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}") + + // Adds notifications to users. + mentions.forEach { + it.addTaggedPost(note) + } + replyTo.forEach { + it.author?.addTaggedPost(note) } // Counts the replies - it.replyTo?.forEach { replyingNote -> - it.removeReply(it) + replyTo.forEach { + it.addReply(note) } - } - println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}") - } - } - - fun pruneNonFollows(account: Account) { - val follows = account.userProfile().follows - val knownPMs = account.userProfile().privateChatrooms.filter { - account.userProfile().hasSentMessagesTo(it.key) && account.isAcceptable(it.key) + refreshObservers() } - val followsFollow = follows.map { - it.follows - }.flatten() - - val followSet = follows.plus(knownPMs).plus(account.userProfile()).plus(followsFollow) - - val toBeRemoved = notes - .filter { - (it.value.author == null || it.value.author!! !in followSet) && it.value.event?.kind == TextNoteEvent.kind && it.value.liveSet?.isInUse() != true - } - - toBeRemoved.forEach { - notes.remove(it.key) + fun consume(event: ChannelHideMessageEvent) { } - val toBeRemovedUsers = users - .filter { - (it.value !in followSet) && it.value.liveSet?.isInUse() != true - } - - toBeRemovedUsers.forEach { - users.remove(it.key) + fun consume(event: ChannelMuteUserEvent) { } - println("PRUNE: ${toBeRemoved.size} messages removed because they came from NonFollows") - println("PRUNE: ${toBeRemovedUsers.size} users removed because are NonFollows") - } + fun consume(event: LnZapEvent) { + val note = getOrCreateNote(event.id) - fun pruneHiddenMessages(account: Account) { - val toBeRemoved = account.hiddenUsers.map { - (users[it]?.notes ?: emptySet()) - }.flatten() + // Already processed this event. + if (note.event != null) return - account.hiddenUsers.forEach { - users[it]?.clearNotes() + val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) } + + val author = getOrCreateUser(event.pubKey) + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + ((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet()) + + note.loadEvent(event, author, mentions, repliesTo) + + if (zapRequest == null) { + Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") + return + } + + // Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + // Adds notifications to users. + mentions.forEach { + it.addTaggedPost(note) + } + repliesTo.forEach { + it.author?.addTaggedPost(note) + } + + repliesTo.forEach { + it.addZap(zapRequest, note) + } + mentions.forEach { + it.addZap(zapRequest, note) + } } - toBeRemoved.forEach { - it.author?.removeNote(it) + fun consume(event: LnZapRequestEvent) { + val note = getOrCreateNote(event.id) - // reverts the add - it.mentions?.forEach { user -> - user.removeTaggedPost(it) - } - it.replyTo?.forEach { replyingNote -> - replyingNote.author?.removeTaggedPost(it) - } + // Already processed this event. + if (note.event != null) return - // Counts the replies - it.replyTo?.forEach { masterNote -> - masterNote.removeReply(it) - masterNote.removeBoost(it) - masterNote.removeReaction(it) - masterNote.removeZap(it) - masterNote.removeReport(it) - } + val author = getOrCreateUser(event.pubKey) + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } - notes.remove(it.idHex) + note.loadEvent(event, author, mentions, repliesTo) + + // Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + // Adds notifications to users. + mentions.forEach { + it.addTaggedPost(note) + } + repliesTo.forEach { + it.author?.addTaggedPost(note) + } + + repliesTo.forEach { + it.addZap(note, null) + } + mentions.forEach { + it.addZap(note, null) + } } - println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") - } + fun findUsersStartingWith(username: String): List { + return users.values.filter { + (it.anyNameStartsWith(username)) || + it.pubkeyHex.startsWith(username, true) || + it.pubkeyNpub().startsWith(username, true) + } + } - // Observers line up here. - val live: LocalCacheLiveData = LocalCacheLiveData(this) + fun findNotesStartingWith(text: String): List { + return notes.values.filter { + (it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) || + (it.event is ChannelMessageEvent && it.event?.content()?.contains(text, true) ?: false) || + it.idHex.startsWith(text, true) || + it.idNote().startsWith(text, true) + } + addressables.values.filter { + (it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false || + (it.event as? LongTextNoteEvent)?.title()?.contains(text, true) ?: false || + (it.event as? LongTextNoteEvent)?.summary()?.contains(text, true) ?: false || + it.idHex.startsWith(text, true) + } + } - private fun refreshObservers() { - live.invalidateData() - } + fun findChannelsStartingWith(text: String): List { + return channels.values.filter { + it.anyNameStartsWith(text) || + it.idHex.startsWith(text, true) || + it.idNote().startsWith(text, true) + } + } + + fun cleanObservers() { + notes.forEach { + it.value.clearLive() + } + + users.forEach { + it.value.clearLive() + } + } + + fun pruneOldAndHiddenMessages(account: Account) { + channels.forEach { + val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) + + toBeRemoved.forEach { + notes.remove(it.idHex) + // Doesn't need to clean up the replies and mentions.. Too small to matter. + + // reverts the add + it.mentions?.forEach { user -> + user.removeTaggedPost(it) + } + it.replyTo?.forEach { replyingNote -> + replyingNote.author?.removeTaggedPost(it) + } + + // Counts the replies + it.replyTo?.forEach { replyingNote -> + it.removeReply(it) + } + } + + println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}") + } + } + + fun pruneNonFollows(account: Account) { + val follows = account.userProfile().follows + val knownPMs = account.userProfile().privateChatrooms.filter { + account.userProfile().hasSentMessagesTo(it.key) && account.isAcceptable(it.key) + } + + val followsFollow = follows.map { + it.follows + }.flatten() + + val followSet = follows.plus(knownPMs).plus(account.userProfile()).plus(followsFollow) + + val toBeRemoved = notes + .filter { + (it.value.author == null || it.value.author!! !in followSet) && it.value.event?.kind() == TextNoteEvent.kind && it.value.liveSet?.isInUse() != true + } + + toBeRemoved.forEach { + notes.remove(it.key) + } + + val toBeRemovedUsers = users + .filter { + (it.value !in followSet) && it.value.liveSet?.isInUse() != true + } + + toBeRemovedUsers.forEach { + users.remove(it.key) + } + + println("PRUNE: ${toBeRemoved.size} messages removed because they came from NonFollows") + println("PRUNE: ${toBeRemovedUsers.size} users removed because are NonFollows") + } + + fun pruneHiddenMessages(account: Account) { + val toBeRemoved = account.hiddenUsers.map { + (users[it]?.notes ?: emptySet()) + }.flatten() + + account.hiddenUsers.forEach { + users[it]?.clearNotes() + } + + toBeRemoved.forEach { + it.author?.removeNote(it) + + // reverts the add + it.mentions?.forEach { user -> + user.removeTaggedPost(it) + } + it.replyTo?.forEach { replyingNote -> + replyingNote.author?.removeTaggedPost(it) + } + + // Counts the replies + it.replyTo?.forEach { masterNote -> + masterNote.removeReply(it) + masterNote.removeBoost(it) + masterNote.removeReaction(it) + masterNote.removeZap(it) + masterNote.removeReport(it) + } + + notes.remove(it.idHex) + } + + println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") + } + + // Observers line up here. + val live: LocalCacheLiveData = LocalCacheLiveData(this) + + private fun refreshObservers() { + live.invalidateData() + } } -class LocalCacheLiveData(val cache: LocalCache): LiveData(LocalCacheState(cache)) { +class LocalCacheLiveData(val cache: LocalCache) : LiveData(LocalCacheState(cache)) { - // Refreshes observers in batches. - var handlerWaiting = AtomicBoolean() + // Refreshes observers in batches. + var handlerWaiting = AtomicBoolean() - fun invalidateData() { - if (!hasActiveObservers()) return - if (handlerWaiting.getAndSet(true)) return + fun invalidateData() { + if (!hasActiveObservers()) return + if (handlerWaiting.getAndSet(true)) return - val scope = CoroutineScope(Job() + Dispatchers.Main) - scope.launch { - try { - delay(50) - refresh() - } finally { - withContext(NonCancellable) { - handlerWaiting.set(false) + val scope = CoroutineScope(Job() + Dispatchers.Main) + scope.launch { + try { + delay(50) + refresh() + } finally { + withContext(NonCancellable) { + handlerWaiting.set(false) + } + } } - } } - } - private fun refresh() { - postValue(LocalCacheState(cache)) - } + private fun refresh() { + postValue(LocalCacheState(cache)) + } } -class LocalCacheState(val cache: LocalCache) { - -} \ No newline at end of file +class LocalCacheState(val cache: LocalCache) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index f8bfa09a20..8437d532b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -2,24 +2,10 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource -import com.vitorpamplona.amethyst.service.model.ATag -import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent -import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.service.model.LnZapEvent -import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.model.ReactionEvent -import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.note.toShortenHex import fr.acinq.secp256k1.Hex -import java.math.BigDecimal -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Date -import java.util.concurrent.atomic.AtomicBoolean -import java.util.regex.Pattern import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,22 +13,27 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.vitorpamplona.amethyst.service.model.Event +import java.math.BigDecimal +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.concurrent.atomic.AtomicBoolean +import java.util.regex.Pattern val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]") - -class AddressableNote(val address: ATag): Note(address.toNAddr()) { +class AddressableNote(val address: ATag) : Note(address.toTag()) { override fun idNote() = address.toNAddr() override fun idDisplayNote() = idNote().toShortenHex() override fun address() = address - override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt + override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt() } open class Note(val idHex: String) { // These fields are only available after the Text Note event is received. // They are immutable after that. - var event: Event? = null + var event: EventInterface? = null var author: User? = null var mentions: List? = null var replyTo: List? = null @@ -70,16 +61,16 @@ open class Note(val idHex: String) { fun channel(): Channel? { val channelHex = - (event as? ChannelMessageEvent)?.channel() ?: - (event as? ChannelMetadataEvent)?.channel() ?: - (event as? ChannelCreateEvent)?.let { it.id } + (event as? ChannelMessageEvent)?.channel() + ?: (event as? ChannelMetadataEvent)?.channel() + ?: (event as? ChannelCreateEvent)?.let { it.id } return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) } } open fun address() = (event as? LongTextNoteEvent)?.address() - open fun createdAt() = event?.createdAt + open fun createdAt() = event?.createdAt() fun loadEvent(event: Event, author: User, mentions: List, replyTo: List) { this.event = event @@ -145,7 +136,7 @@ open class Note(val idHex: String) { fun removeReport(deleteNote: Note) { val author = deleteNote.author ?: return - if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) { + if (author in reports.keys && reports[author]?.contains(deleteNote) == true) { reports[author]?.let { reports = reports + Pair(author, it.minus(deleteNote)) liveSet?.reports?.invalidateData() @@ -164,7 +155,6 @@ open class Note(val idHex: String) { } } - fun addBoost(note: Note) { if (note !in boosts) { boosts = boosts + note @@ -248,52 +238,52 @@ open class Note(val idHex: String) { } fun hasAnyReports(): Boolean { - val dayAgo = Date().time / 1000 - 24*60*60 + val dayAgo = Date().time / 1000 - 24 * 60 * 60 return reports.isNotEmpty() || - (author?.reports?.values?.filter { - it.firstOrNull { ( it.createdAt() ?: 0 ) > dayAgo } != null - }?.isNotEmpty() ?: false) + ( + author?.reports?.values?.filter { + it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null + }?.isNotEmpty() ?: false + ) } fun directlyCiteUsersHex(): Set { - val matcher = tagSearch.matcher(event?.content ?: "") + val matcher = tagSearch.matcher(event?.content() ?: "") val returningList = mutableSetOf() while (matcher.find()) { try { - val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) } + val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) } if (tag != null && tag[0] == "p") { returningList.add(tag[1]) } } catch (e: Exception) { - } } return returningList } fun directlyCiteUsers(): Set { - val matcher = tagSearch.matcher(event?.content ?: "") + val matcher = tagSearch.matcher(event?.content() ?: "") val returningList = mutableSetOf() while (matcher.find()) { try { - val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) } + val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) } if (tag != null && tag[0] == "p") { LocalCache.checkGetOrCreateUser(tag[1])?.let { returningList.add(it) } } } catch (e: Exception) { - } } return returningList } fun directlyCites(userProfile: User): Boolean { - return author == userProfile - || (userProfile in directlyCiteUsers()) - || (event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) - || (event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) + return author == userProfile || + (userProfile in directlyCiteUsers()) || + (event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) || + (event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) } fun isNewThread(): Boolean { @@ -309,12 +299,12 @@ open class Note(val idHex: String) { } fun reactedBy(loggedIn: User, content: String): List { - return reactions.filter { it.author == loggedIn && it.event?.content == content } + return reactions.filter { it.author == loggedIn && it.event?.content() == content } } fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { val currentTime = Date().time / 1000 - return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection + return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5) } != null // 5 minute protection } fun boostedBy(loggedIn: User): List { @@ -337,7 +327,6 @@ open class Note(val idHex: String) { } } - class NoteLiveSet(u: Note) { // Observers line up here. val metadata: NoteLiveData = NoteLiveData(u) @@ -350,17 +339,17 @@ class NoteLiveSet(u: Note) { val zaps: NoteLiveData = NoteLiveData(u) fun isInUse(): Boolean { - return metadata.hasObservers() - || reactions.hasObservers() - || boosts.hasObservers() - || replies.hasObservers() - || reports.hasObservers() - || relays.hasObservers() - || zaps.hasObservers() + return metadata.hasObservers() || + reactions.hasObservers() || + boosts.hasObservers() || + replies.hasObservers() || + reports.hasObservers() || + relays.hasObservers() || + zaps.hasObservers() } } -class NoteLiveData(val note: Note): LiveData(NoteState(note)) { +class NoteLiveData(val note: Note) : LiveData(NoteState(note)) { // Refreshes observers in batches. var handlerWaiting = AtomicBoolean() @@ -392,7 +381,6 @@ class NoteLiveData(val note: Note): LiveData(NoteState(note)) { } else { NostrSingleEventDataSource.add(note) } - } override fun onInactive() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt index ddaf9f7283..1f11bff544 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt @@ -11,4 +11,4 @@ data class RelaySetupInfo( val uploadCount: Int = 0, val spamCount: Int = 0, val feedTypes: Set -) \ No newline at end of file +) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index be2366c661..8b76177844 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -6,71 +6,72 @@ import kotlin.time.measureTimedValue class ThreadAssembler { - fun searchRoot(note: Note, testedNotes: MutableSet = mutableSetOf()): Note? { - if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note + fun searchRoot(note: Note, testedNotes: MutableSet = mutableSetOf()): Note? { + if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note - testedNotes.add(note) + testedNotes.add(note) - val markedAsRoot = note.event?.tags?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) - if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot) + val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) + if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot) - val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true } - if (hasNoReplyTo != null) return hasNoReplyTo + val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true } + if (hasNoReplyTo != null) return hasNoReplyTo - // recursive - val roots = note.replyTo?.map { - if (it !in testedNotes) - searchRoot(it, testedNotes) - else - null - }?.filterNotNull() + // recursive + val roots = note.replyTo?.map { + if (it !in testedNotes) { + searchRoot(it, testedNotes) + } else { + null + } + }?.filterNotNull() - if (roots != null && roots.isNotEmpty()) { - return roots[0] + if (roots != null && roots.isNotEmpty()) { + return roots[0] + } + + return null } - return null - } + @OptIn(ExperimentalTime::class) + fun findThreadFor(noteId: String): Set { + val (result, elapsed) = measureTimedValue { + val note = if (noteId.contains(":")) { + val aTag = ATag.parse(noteId, null) + if (aTag != null) { + LocalCache.getOrCreateAddressableNote(aTag) + } else { + return emptySet() + } + } else { + LocalCache.getOrCreateNote(noteId) + } - @OptIn(ExperimentalTime::class) - fun findThreadFor(noteId: String): Set { - val (result, elapsed) = measureTimedValue { - val note = if (noteId.startsWith("naddr")) { - val aTag = ATag.parse(noteId) - if (aTag != null) - LocalCache.getOrCreateAddressableNote(aTag) - else - return emptySet() - } else { - LocalCache.getOrCreateNote(noteId) - } + if (note.event != null) { + val thread = mutableSetOf() + val threadRoot = searchRoot(note, thread) ?: note - if (note.event != null) { - val thread = mutableSetOf() + loadDown(threadRoot, thread) - val threadRoot = searchRoot(note, thread) ?: note + thread.toSet() + } else { + setOf(note) + } + } - loadDown(threadRoot, thread) + println("Model Refresh: Thread loaded in $elapsed") - thread.toSet() - } else { - setOf(note) - } + return result } - println("Model Refresh: Thread loaded in ${elapsed}") + fun loadDown(note: Note, thread: MutableSet) { + if (note !in thread) { + thread.add(note) - return result - } - - fun loadDown(note: Note, thread: MutableSet) { - if (note !in thread) { - thread.add(note) - - note.replies.forEach { - loadDown(it, thread) - } + note.replies.forEach { + loadDown(it, thread) + } + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index 49471bf4a4..e49cdf6301 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -13,60 +13,63 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch object UrlCachedPreviewer { - var cache = mapOf() - private set - var failures = mapOf() - private set + var cache = mapOf() + private set + var failures = mapOf() + private set - fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) { - cache[url]?.let { - callback?.onComplete(it) - return - } - - failures[url]?.let { - callback?.onFailed(it) - return - } - - val scope = CoroutineScope(Job() + Dispatchers.IO) - scope.launch { - BahaUrlPreview(url, object : IUrlPreviewCallback { - override fun onComplete(urlInfo: UrlInfoItem) { - cache = cache + Pair(url, urlInfo) - callback?.onComplete(urlInfo) + fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) { + cache[url]?.let { + callback?.onComplete(it) + return } - override fun onFailed(throwable: Throwable) { - failures = failures + Pair(url, throwable) - callback?.onFailed(throwable) + failures[url]?.let { + callback?.onFailed(it) + return } - }).fetchUrlPreview() - } - } - fun findUrlsInMessage(message: String): List { - return message.split('\n').map { paragraph -> - paragraph.split(' ').filter { word: String -> - isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() - } - }.flatten() - } + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + BahaUrlPreview( + url, + object : IUrlPreviewCallback { + override fun onComplete(urlInfo: UrlInfoItem) { + cache = cache + Pair(url, urlInfo) + callback?.onComplete(urlInfo) + } - fun preloadPreviewsFor(note: Note) { - note.event?.content?.let { - findUrlsInMessage(it).forEach { - val removedParamsFromUrl = it.split("?")[0].lowercase() - if (imageExtension.matcher(removedParamsFromUrl).matches()) { - // Preload Images? Isn't this too heavy? - } else if (videoExtension.matcher(removedParamsFromUrl).matches()) { - // Do nothing for now. - } else if (isValidURL(removedParamsFromUrl)) { - previewInfo(it) - } else { - previewInfo("https://${it}") + override fun onFailed(throwable: Throwable) { + failures = failures + Pair(url, throwable) + callback?.onFailed(throwable) + } + } + ).fetchUrlPreview() + } + } + + fun findUrlsInMessage(message: String): List { + return message.split('\n').map { paragraph -> + paragraph.split(' ').filter { word: String -> + isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() + } + }.flatten() + } + + fun preloadPreviewsFor(note: Note) { + note.event?.content()?.let { + findUrlsInMessage(it).forEach { + val removedParamsFromUrl = it.split("?")[0].lowercase() + if (imageExtension.matcher(removedParamsFromUrl).matches()) { + // Preload Images? Isn't this too heavy? + } else if (videoExtension.matcher(removedParamsFromUrl).matches()) { + // Do nothing for now. + } else if (isValidURL(removedParamsFromUrl)) { + previewInfo(it) + } else { + previewInfo("https://$it") + } + } } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 153c6167df..a312c301bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -2,14 +2,13 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource +import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.note.toShortenHex import fr.acinq.secp256k1.Hex -import java.math.BigDecimal -import java.util.concurrent.atomic.AtomicBoolean -import java.util.regex.Pattern import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -18,9 +17,10 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import nostr.postr.Bech32 -import com.vitorpamplona.amethyst.service.model.ContactListEvent -import com.vitorpamplona.amethyst.service.model.MetadataEvent import nostr.postr.toNpub +import java.math.BigDecimal +import java.util.concurrent.atomic.AtomicBoolean +import java.util.regex.Pattern val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)") @@ -29,7 +29,7 @@ class Badges(val definition: Note, val awardees: Set) class User(val pubkeyHex: String) { var info: UserMetadata? = null - var updatedFollowsAt: Long = 0; + var updatedFollowsAt: Long = 0 var latestContactList: ContactListEvent? = null var follows = setOf() @@ -69,6 +69,8 @@ class User(val pubkeyHex: String) { fun pubkeyNpub() = pubkey().toNpub() fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() + override fun toString(): String = pubkeyHex + fun toBestDisplayName(): String { return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex() } @@ -175,7 +177,7 @@ class User(val pubkeyHex: String) { fun removeReport(deleteNote: Note) { val author = deleteNote.author ?: return - if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) { + if (author in reports.keys && reports[author]?.contains(deleteNote) == true) { reports[author]?.let { reports = reports + Pair(author, it.minus(deleteNote)) liveSet?.reports?.invalidateData() @@ -266,7 +268,7 @@ class User(val pubkeyHex: String) { liveSet?.relayInfo?.invalidateData() } - + fun updateFollows(newFollows: Set, updateAt: Long) { val toBeAdded = newFollows - follows val toBeRemoved = follows - newFollows @@ -319,7 +321,7 @@ class User(val pubkeyHex: String) { fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean { return reports[loggedIn]?.firstOrNull() { - it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type } + it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type } } != null } @@ -341,8 +343,6 @@ class User(val pubkeyHex: String) { liveSet = null } } - - } class UserLiveSet(u: User) { @@ -357,18 +357,18 @@ class UserLiveSet(u: User) { val badges: UserLiveData = UserLiveData(u) fun isInUse(): Boolean { - return follows.hasObservers() - || reports.hasObservers() - || messages.hasObservers() - || relays.hasObservers() - || relayInfo.hasObservers() - || metadata.hasObservers() - || zaps.hasObservers() - || badges.hasObservers() + return follows.hasObservers() || + reports.hasObservers() || + messages.hasObservers() || + relays.hasObservers() || + relayInfo.hasObservers() || + metadata.hasObservers() || + zaps.hasObservers() || + badges.hasObservers() } } -data class RelayInfo ( +data class RelayInfo( val url: String, var lastEvent: Long, var counter: Long @@ -376,7 +376,6 @@ data class RelayInfo ( data class Chatroom(var roomMessages: Set) - class UserMetadata { var name: String? = null var username: String? = null @@ -400,16 +399,16 @@ class UserMetadata { var main_relay: String? = null var twitter: String? = null - var updatedMetadataAt: Long = 0; + var updatedMetadataAt: Long = 0 var latestMetadata: MetadataEvent? = null fun anyNameStartsWith(prefix: String): Boolean { return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16) - .any { it.startsWith(prefix, true) } + .any { it.startsWith(prefix, true) } } } -class UserLiveData(val user: User): LiveData(UserState(user)) { +class UserLiveData(val user: User) : LiveData(UserState(user)) { // Refreshes observers in batches. var handlerWaiting = AtomicBoolean() @@ -446,4 +445,3 @@ class UserLiveData(val user: User): LiveData(UserState(user)) { } class UserState(val user: User) - diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt index 52b42ee56b..28bb298f45 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt @@ -13,81 +13,83 @@ import okhttp3.Request import okhttp3.Response class Nip05Verifier { - val client = OkHttpClient.Builder().build() + val client = OkHttpClient.Builder().build() - fun assembleUrl(nip05address: String): String? { - val parts = nip05address.trim().split("@") + fun assembleUrl(nip05address: String): String? { + val parts = nip05address.trim().split("@") - if (parts.size == 2) { - return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}" + if (parts.size == 2) { + return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}" + } + + return null } - return null - } - - fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - val scope = CoroutineScope(Job() + Dispatchers.IO) - scope.launch { - fetchNip05JsonSuspend(lnaddress, onSuccess, onError) - } - } - - private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - val url = assembleUrl(nip05) - - if (url == null) { - onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup") - return + fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + fetchNip05JsonSuspend(lnaddress, onSuccess, onError) + } } - withContext(Dispatchers.IO) { - try { - val request: Request = Request.Builder().url(url).build() + private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + val url = assembleUrl(nip05) - client.newCall(request).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - response.use { - if (it.isSuccessful) - onSuccess(it.body.string()) - else - onError("Could not resolve ${nip05}. Error: ${it.code}. Check if the server up and if the address ${nip05} is correct") + if (url == null) { + onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup") + return + } + + withContext(Dispatchers.IO) { + try { + val request: Request = Request.Builder().url(url).build() + + client.newCall(request).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + response.use { + if (it.isSuccessful) { + onSuccess(it.body.string()) + } else { + onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct") + } + } + } + + override fun onFailure(call: Call, e: java.io.IOException) { + onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct") + e.printStackTrace() + } + }) + } catch (e: java.lang.Exception) { + onError("Could not resolve '$url': ${e.message}") } - } - - override fun onFailure(call: Call, e: java.io.IOException) { - onError("Could not resolve ${url}. Check if the server up and if the address ${nip05} is correct") - e.printStackTrace() - } - }) - } catch (e: java.lang.Exception) { - onError("Could not resolve '${url}': ${e.message}") - } + } } - } - fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - val mapper = jacksonObjectMapper() + fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + val mapper = jacksonObjectMapper() - fetchNip05Json(nip05, - onSuccess = { - val nip05url = try { - mapper.readTree(it) - } catch (t: Throwable) { - onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup") - null - } + fetchNip05Json( + nip05, + onSuccess = { + val nip05url = try { + mapper.readTree(it) + } catch (t: Throwable) { + onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup") + null + } - val user = nip05.split("@")[0] + val user = nip05.split("@")[0] - val hexKey = nip05url?.get("names")?.get(user)?.asText() + val hexKey = nip05url?.get("names")?.get(user)?.asText() - if (hexKey == null) { - onError("Username not found in the NIP05 JSON") - } else { - onSuccess(hexKey) - } - }, - onError = onError - ) - } -} \ No newline at end of file + if (hexKey == null) { + onError("Username not found in the NIP05 JSON") + } else { + onSuccess(hexKey) + } + }, + onError = onError + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt deleted file mode 100644 index 54d765940f..0000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.vitorpamplona.amethyst.service - -import com.vitorpamplona.amethyst.model.toHexKey -import nostr.postr.bechToBytes -import java.nio.ByteBuffer -import java.nio.ByteOrder - -class Nip19 { - - enum class Type { - USER, NOTE, RELAY, ADDRESS - } - - data class Return(val type: Type, val hex: String) - - fun uriToRoute(uri: String?): Return? { - try { - val key = uri?.removePrefix("nostr:") ?: return null - - val bytes = key.bechToBytes() - if (key.startsWith("npub")) { - return npub(bytes) - } else if (key.startsWith("note")) { - return note(bytes) - } else if (key.startsWith("nprofile")) { - return nprofile(bytes) - } else if (key.startsWith("nevent")) { - return nevent(bytes) - } else if (key.startsWith("nrelay")) { - return nrelay(bytes) - } else if (key.startsWith("naddr")) { - return naddr(bytes) - } - } catch (e: Throwable) { - println("Issue trying to Decode NIP19 ${uri}: ${e.message}") - } - - return null - } - - private fun npub(bytes: ByteArray): Return { - return Return(Type.USER, bytes.toHexKey()) - } - - private fun note(bytes: ByteArray): Return { - return Return(Type.NOTE, bytes.toHexKey()); - } - - private fun nprofile(bytes: ByteArray): Return? { - val hex = parseTLV(bytes) - .get(NIP19TLVTypes.SPECIAL.id) - ?.get(0) - ?.toHexKey() ?: return null - - return Return(Type.USER, hex) - } - - private fun nevent(bytes: ByteArray): Return? { - val hex = parseTLV(bytes) - .get(NIP19TLVTypes.SPECIAL.id) - ?.get(0) - ?.toHexKey() ?: return null - - return Return(Type.USER, hex) - } - - private fun nrelay(bytes: ByteArray): Return? { - val relayUrl = parseTLV(bytes) - .get(NIP19TLVTypes.SPECIAL.id) - ?.get(0) - ?.toString(Charsets.UTF_8) ?: return null - - return Return(Type.RELAY, relayUrl) - } - - private fun naddr(bytes: ByteArray): Return? { - val tlv = parseTLV(bytes) - - val d = tlv.get(NIP19TLVTypes.SPECIAL.id) - ?.get(0) - ?.toString(Charsets.UTF_8) ?: return null - - val relay = tlv.get(NIP19TLVTypes.RELAY.id) - ?.get(0) - ?.toString(Charsets.UTF_8) - - val author = tlv.get(NIP19TLVTypes.AUTHOR.id) - ?.get(0) - ?.toHexKey() - - val kind = tlv.get(NIP19TLVTypes.KIND.id) - ?.get(0) - ?.let { toInt32(it) } - - return Return(Type.ADDRESS, "$kind:$author:$d") - } -} - -// Classes should start with an uppercase letter in kotlin -enum class NIP19TLVTypes(val id: Byte) { - SPECIAL(0), - RELAY(1), - AUTHOR(2), - KIND(3); -} - -fun toInt32(bytes: ByteArray): Int { - require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" } - return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int -} - -fun parseTLV(data: ByteArray): Map> { - val result = mutableMapOf>() - var rest = data - while (rest.isNotEmpty()) { - val t = rest[0] - val l = rest[1] - val v = rest.sliceArray(IntRange(2, (2 + l) - 1)) - rest = rest.sliceArray(IntRange(2 + l, rest.size - 1)) - if (v.size < l) continue - - if (!result.containsKey(t)) { - result[t] = mutableListOf() - } - result[t]?.add(v) - } - return result -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 1ff1b4a41d..fac4279b97 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -4,84 +4,90 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent -import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter -import com.vitorpamplona.amethyst.service.relays.JsonFilter -import com.vitorpamplona.amethyst.service.model.ContactListEvent -import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter -object NostrAccountDataSource: NostrDataSource("AccountData") { - lateinit var account: Account +object NostrAccountDataSource : NostrDataSource("AccountData") { + lateinit var account: Account - fun createAccountContactListFilter(): TypedFilter { - return TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(ContactListEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - limit = 1 - ) + fun createAccountContactListFilter(): TypedFilter { + return TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(ContactListEvent.kind), + authors = listOf(account.userProfile().pubkeyHex), + limit = 1 + ) + ) + } + + fun createAccountMetadataFilter(): TypedFilter { + return TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(MetadataEvent.kind), + authors = listOf(account.userProfile().pubkeyHex), + limit = 1 + ) + ) + } + + fun createAccountAcceptedAwardsFilter(): TypedFilter { + return TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(BadgeProfilesEvent.kind), + authors = listOf(account.userProfile().pubkeyHex), + limit = 1 + ) + ) + } + + fun createAccountReportsFilter(): TypedFilter { + return TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(ReportEvent.kind), + authors = listOf(account.userProfile().pubkeyHex) + ) + ) + } + + fun createNotificationFilter() = TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf( + TextNoteEvent.kind, + ReactionEvent.kind, + RepostEvent.kind, + ReportEvent.kind, + LnZapEvent.kind, + ChannelMessageEvent.kind, + BadgeAwardEvent.kind + ), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + limit = 200 + ) ) - } - fun createAccountMetadataFilter(): TypedFilter { - return TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - limit = 1 - ) - ) - } + val accountChannel = requestNewChannel() - fun createAccountAcceptedAwardsFilter(): TypedFilter { - return TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(BadgeProfilesEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - limit = 1 - ) - ) - } - - fun createAccountReportsFilter(): TypedFilter { - return TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(ReportEvent.kind), - authors = listOf(account.userProfile().pubkeyHex) - ) - ) - } - - fun createNotificationFilter() = TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind, BadgeAwardEvent.kind - ), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 200 - ) - ) - - val accountChannel = requestNewChannel() - - override fun updateChannelFilters() { - // gets everthing about the user logged in - accountChannel.typedFilters = listOf( - createAccountMetadataFilter(), - createAccountContactListFilter(), - createNotificationFilter(), - createAccountReportsFilter(), - createAccountAcceptedAwardsFilter() - ).ifEmpty { null } - } -} \ No newline at end of file + override fun updateChannelFilters() { + // gets everthing about the user logged in + accountChannel.typedFilters = listOf( + createAccountMetadataFilter(), + createAccountContactListFilter(), + createNotificationFilter(), + createAccountReportsFilter(), + createAccountAcceptedAwardsFilter() + ).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt index 72c2b5a4a1..2b3845e8cd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt @@ -4,34 +4,34 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter -object NostrChannelDataSource: NostrDataSource("ChatroomFeed") { - var channel: Channel? = null +object NostrChannelDataSource : NostrDataSource("ChatroomFeed") { + var channel: Channel? = null - fun loadMessagesBetween(channelId: String) { - channel = LocalCache.getOrCreateChannel(channelId) - resetFilters() - } - - fun createMessagesToChannelFilter(): TypedFilter? { - if (channel != null) { - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(ChannelMessageEvent.kind), - tags = mapOf("e" to listOfNotNull(channel?.idHex)), - limit = 200 - ) - ) + fun loadMessagesBetween(channelId: String) { + channel = LocalCache.getOrCreateChannel(channelId) + resetFilters() } - return null - } - val messagesChannel = requestNewChannel() + fun createMessagesToChannelFilter(): TypedFilter? { + if (channel != null) { + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = JsonFilter( + kinds = listOf(ChannelMessageEvent.kind), + tags = mapOf("e" to listOfNotNull(channel?.idHex)), + limit = 200 + ) + ) + } + return null + } - override fun updateChannelFilters() { - messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null } - } -} \ No newline at end of file + val messagesChannel = requestNewChannel() + + override fun updateChannelFilters() { + messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt index 4bb33fef98..b94abf0bb5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt @@ -3,58 +3,58 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter -import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter -object NostrChatroomDataSource: NostrDataSource("ChatroomFeed") { - lateinit var account: Account - var withUser: User? = null +object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") { + lateinit var account: Account + var withUser: User? = null - fun loadMessagesBetween(accountIn: Account, userId: String) { - account = accountIn - withUser = LocalCache.users[userId] - resetFilters() - } - - fun createMessagesToMeFilter(): TypedFilter? { - val myPeer = withUser - - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = JsonFilter( - kinds = listOf(PrivateDmEvent.kind), - authors = listOf(myPeer.pubkeyHex) , - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)) - ) - ) - } else { - null + fun loadMessagesBetween(accountIn: Account, userId: String) { + account = accountIn + withUser = LocalCache.users[userId] + resetFilters() } - } - fun createMessagesFromMeFilter(): TypedFilter? { - val myPeer = withUser - - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = JsonFilter( - kinds = listOf(PrivateDmEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to listOf(myPeer.pubkeyHex)) - ) - ) - } else { - null + fun createMessagesToMeFilter(): TypedFilter? { + val myPeer = withUser + + return if (myPeer != null) { + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = JsonFilter( + kinds = listOf(PrivateDmEvent.kind), + authors = listOf(myPeer.pubkeyHex), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)) + ) + ) + } else { + null + } } - } - val inandoutChannel = requestNewChannel() + fun createMessagesFromMeFilter(): TypedFilter? { + val myPeer = withUser - override fun updateChannelFilters() { - inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null } - } -} \ No newline at end of file + return if (myPeer != null) { + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = JsonFilter( + kinds = listOf(PrivateDmEvent.kind), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to listOf(myPeer.pubkeyHex)) + ) + ) + } else { + null + } + } + + val inandoutChannel = requestNewChannel() + + override fun updateChannelFilters() { + inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index 850e90872c..d3d0833552 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -4,85 +4,85 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter -import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter -object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") { - lateinit var account: Account +object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { + lateinit var account: Account - fun createMessagesToMeFilter() = TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = JsonFilter( - kinds = listOf(PrivateDmEvent.kind), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)) - ) - ) - - fun createMessagesFromMeFilter() = TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = JsonFilter( - kinds = listOf(PrivateDmEvent.kind), - authors = listOf(account.userProfile().pubkeyHex) - ) - ) - - fun createChannelsCreatedbyMeFilter() = TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind), - authors = listOf(account.userProfile().pubkeyHex) - ) - ) - - fun createMyChannelsFilter() = TypedFilter( - types = FeedType.values().toSet(), // Metadata comes from any relay - filter = JsonFilter( - kinds = listOf(ChannelCreateEvent.kind), - ids = account.followingChannels.toList() - ) - ) - - fun createLastChannelInfoFilter(): List { - return account.followingChannels.map { - TypedFilter( - types = FeedType.values().toSet(), // Metadata comes from any relay + fun createMessagesToMeFilter() = TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), filter = JsonFilter( - kinds = listOf(ChannelMetadataEvent.kind), - tags = mapOf("e" to listOf(it)), - limit = 1 + kinds = listOf(PrivateDmEvent.kind), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)) ) - ) - } - } + ) - fun createLastMessageOfEachChannelFilter(): List { - return account.followingChannels.map { - TypedFilter( + fun createMessagesFromMeFilter() = TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = JsonFilter( + kinds = listOf(PrivateDmEvent.kind), + authors = listOf(account.userProfile().pubkeyHex) + ) + ) + + fun createChannelsCreatedbyMeFilter() = TypedFilter( types = setOf(FeedType.PUBLIC_CHATS), filter = JsonFilter( - kinds = listOf(ChannelMessageEvent.kind), - tags = mapOf("e" to listOf(it)), - limit = 100 // Remember to consider spam that is being removed from the UI + kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind), + authors = listOf(account.userProfile().pubkeyHex) ) - ) - } - } - - val chatroomListChannel = requestNewChannel() - - override fun updateChannelFilters() { - val list = listOf( - createMessagesToMeFilter(), - createMessagesFromMeFilter(), - createMyChannelsFilter() ) - chatroomListChannel.typedFilters = listOfNotNull( - list, - createLastChannelInfoFilter(), - createLastMessageOfEachChannelFilter() - ).flatten().ifEmpty { null } - } -} \ No newline at end of file + fun createMyChannelsFilter() = TypedFilter( + types = FeedType.values().toSet(), // Metadata comes from any relay + filter = JsonFilter( + kinds = listOf(ChannelCreateEvent.kind), + ids = account.followingChannels.toList() + ) + ) + + fun createLastChannelInfoFilter(): List { + return account.followingChannels.map { + TypedFilter( + types = FeedType.values().toSet(), // Metadata comes from any relay + filter = JsonFilter( + kinds = listOf(ChannelMetadataEvent.kind), + tags = mapOf("e" to listOf(it)), + limit = 1 + ) + ) + } + } + + fun createLastMessageOfEachChannelFilter(): List { + return account.followingChannels.map { + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = JsonFilter( + kinds = listOf(ChannelMessageEvent.kind), + tags = mapOf("e" to listOf(it)), + limit = 100 // Remember to consider spam that is being removed from the UI + ) + ) + } + } + + val chatroomListChannel = requestNewChannel() + + override fun updateChannelFilters() { + val list = listOf( + createMessagesToMeFilter(), + createMessagesFromMeFilter(), + createMyChannelsFilter() + ) + + chatroomListChannel.typedFilters = listOfNotNull( + list, + createLastChannelInfoFilter(), + createLastMessageOfEachChannelFilter() + ).flatten().ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index d248068ddc..961e850a8f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -10,18 +10,22 @@ import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent +import com.vitorpamplona.amethyst.service.model.ContactListEvent +import com.vitorpamplona.amethyst.service.model.DeletionEvent +import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.MetadataEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent +import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Subscription -import java.util.Date -import java.util.UUID -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -29,196 +33,190 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.vitorpamplona.amethyst.service.model.ContactListEvent -import com.vitorpamplona.amethyst.service.model.DeletionEvent -import com.vitorpamplona.amethyst.service.model.Event -import com.vitorpamplona.amethyst.service.model.MetadataEvent -import com.vitorpamplona.amethyst.service.model.PrivateDmEvent -import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import java.util.Date +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean abstract class NostrDataSource(val debugName: String) { - private var subscriptions = mapOf() - data class Counter(var counter:Int) + private var subscriptions = mapOf() + data class Counter(var counter: Int) - private var eventCounter = mapOf() + private var eventCounter = mapOf() - fun printCounter() { - eventCounter.forEach { - println("AAA Count ${it.key}: ${it.value.counter}") - } - } - - - private val clientListener = object : Client.Listener() { - override fun onEvent(event: Event, subscriptionId: String, relay: Relay) { - if (subscriptionId in subscriptions.keys) { - if (!event.hasValidSignature()) return - - val key = "${debugName} ${subscriptionId} ${event.kind}" - val keyValue = eventCounter.get(key) - if (keyValue != null) { - keyValue.counter++ - } else { - eventCounter = eventCounter + Pair(key, Counter(1)) + fun printCounter() { + eventCounter.forEach { + println("AAA Count ${it.key}: ${it.value.counter}") } + } - try { - when (event) { - is BadgeAwardEvent -> LocalCache.consume(event) - is BadgeDefinitionEvent -> LocalCache.consume(event) - is BadgeProfilesEvent -> LocalCache.consume(event) - is ChannelCreateEvent -> LocalCache.consume(event) - is ChannelHideMessageEvent -> LocalCache.consume(event) - is ChannelMessageEvent -> LocalCache.consume(event, relay) - is ChannelMetadataEvent -> LocalCache.consume(event) - is ChannelMuteUserEvent -> LocalCache.consume(event) - is ContactListEvent -> LocalCache.consume(event) - is DeletionEvent -> LocalCache.consume(event) + private val clientListener = object : Client.Listener() { + override fun onEvent(event: Event, subscriptionId: String, relay: Relay) { + if (subscriptionId in subscriptions.keys) { + if (!event.hasValidSignature()) return - is LnZapEvent -> { - event.containedPost()?.let { onEvent(it, subscriptionId, relay) } - LocalCache.consume(event) + val key = "$debugName $subscriptionId ${event.kind}" + val keyValue = eventCounter.get(key) + if (keyValue != null) { + keyValue.counter++ + } else { + eventCounter = eventCounter + Pair(key, Counter(1)) + } + + try { + when (event) { + is BadgeAwardEvent -> LocalCache.consume(event) + is BadgeDefinitionEvent -> LocalCache.consume(event) + is BadgeProfilesEvent -> LocalCache.consume(event) + is ChannelCreateEvent -> LocalCache.consume(event) + is ChannelHideMessageEvent -> LocalCache.consume(event) + is ChannelMessageEvent -> LocalCache.consume(event, relay) + is ChannelMetadataEvent -> LocalCache.consume(event) + is ChannelMuteUserEvent -> LocalCache.consume(event) + is ContactListEvent -> LocalCache.consume(event) + is DeletionEvent -> LocalCache.consume(event) + + is LnZapEvent -> { + event.containedPost()?.let { onEvent(it, subscriptionId, relay) } + LocalCache.consume(event) + } + is LnZapRequestEvent -> LocalCache.consume(event) + is LongTextNoteEvent -> LocalCache.consume(event, relay) + is MetadataEvent -> LocalCache.consume(event) + is PrivateDmEvent -> LocalCache.consume(event, relay) + is ReactionEvent -> LocalCache.consume(event) + is RecommendRelayEvent -> LocalCache.consume(event) + is ReportEvent -> LocalCache.consume(event, relay) + is RepostEvent -> { + event.containedPost()?.let { onEvent(it, subscriptionId, relay) } + LocalCache.consume(event) + } + is TextNoteEvent -> LocalCache.consume(event, relay) + else -> { + Log.w("Event Not Supported", event.toJson()) + } + } + } catch (e: Exception) { + e.printStackTrace() + } } - is LnZapRequestEvent -> LocalCache.consume(event) - is LongTextNoteEvent -> LocalCache.consume(event, relay) - is MetadataEvent -> LocalCache.consume(event) - is PrivateDmEvent -> LocalCache.consume(event, relay) - is ReactionEvent -> LocalCache.consume(event) - is RecommendRelayEvent -> LocalCache.consume(event) - is ReportEvent -> LocalCache.consume(event, relay) - is RepostEvent -> { - event.containedPost()?.let { onEvent(it, subscriptionId, relay) } - LocalCache.consume(event) + } + + override fun onError(error: Error, subscriptionId: String, relay: Relay) { + // Log.e("ERROR", "Relay ${relay.url}: ${error.message}") + } + + override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) { + // Log.d("RELAY", "Relay ${relay.url} ${when (type) { + // Relay.Type.CONNECT -> "connected." + // Relay.Type.DISCONNECT -> "disconnected." + // Relay.Type.DISCONNECTING -> "disconnecting." + // Relay.Type.EOSE -> "sent all events it had stored." + // }}") + + if (type == Relay.Type.EOSE && channel != null) { + // updates a per subscripton since date + subscriptions[channel]?.updateEOSE(Date().time / 1000) } - is TextNoteEvent -> LocalCache.consume(event, relay) - else -> { - Log.w("Event Not Supported", event.toJson()) + } + + override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) { + } + } + + init { + Client.subscribe(clientListener) + } + + open fun start() { + println("DataSource: ${this.javaClass.simpleName} Start") + resetFilters() + } + + open fun stop() { + println("DataSource: ${this.javaClass.simpleName} Stop") + subscriptions.values.forEach { channel -> + Client.close(channel.id) + channel.typedFilters = null + } + } + + fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription { + val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE) + subscriptions = subscriptions + Pair(newSubscription.id, newSubscription) + return newSubscription + } + + fun dismissChannel(subscription: Subscription) { + Client.close(subscription.id) + subscriptions = subscriptions.minus(subscription.id) + } + + // Refreshes observers in batches. + var handlerWaiting = AtomicBoolean() + + fun invalidateFilters() { + if (handlerWaiting.getAndSet(true)) return + + println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + try { + delay(200) + resetFiltersSuspend() + } finally { + withContext(NonCancellable) { + handlerWaiting.set(false) + } } - } - } catch (e: Exception) { - e.printStackTrace() } - } } - override fun onError(error: Error, subscriptionId: String, relay: Relay) { - //Log.e("ERROR", "Relay ${relay.url}: ${error.message}") - } - - override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) { - //Log.d("RELAY", "Relay ${relay.url} ${when (type) { - // Relay.Type.CONNECT -> "connected." - // Relay.Type.DISCONNECT -> "disconnected." - // Relay.Type.DISCONNECTING -> "disconnecting." - // Relay.Type.EOSE -> "sent all events it had stored." - //}}") - - if (type == Relay.Type.EOSE && channel != null) { - // updates a per subscripton since date - subscriptions[channel]?.updateEOSE(Date().time / 1000) - } - } - - override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) { - - } - } - - init { - Client.subscribe(clientListener) - } - - open fun start() { - println("DataSource: ${this.javaClass.simpleName} Start") - resetFilters() - } - - open fun stop() { - println("DataSource: ${this.javaClass.simpleName} Stop") - subscriptions.values.forEach { channel -> - Client.close(channel.id) - channel.typedFilters = null - } - } - - fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription { - val newSubscription = Subscription(UUID.randomUUID().toString().substring(0,4), onEOSE) - subscriptions = subscriptions + Pair(newSubscription.id, newSubscription) - return newSubscription - } - - fun dismissChannel(subscription: Subscription) { - Client.close(subscription.id) - subscriptions = subscriptions.minus(subscription.id) - } - - // Refreshes observers in batches. - var handlerWaiting = AtomicBoolean() - - fun invalidateFilters() { - if (handlerWaiting.getAndSet(true)) return - - println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") - - val scope = CoroutineScope(Job() + Dispatchers.IO) - scope.launch { - try { - delay(200) - resetFiltersSuspend() - } finally { - withContext(NonCancellable) { - handlerWaiting.set(false) + fun resetFilters() { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + resetFiltersSuspend() } - } } - } - fun resetFilters() { - val scope = CoroutineScope(Job() + Dispatchers.IO) - scope.launch { - resetFiltersSuspend() - } - } + fun resetFiltersSuspend() { + // saves the channels that are currently active + val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } + // saves the current content to only update if it changes + val currentFilters = activeSubscriptions.associate { it.id to it.toJson() } - fun resetFiltersSuspend() { - // saves the channels that are currently active - val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } - // saves the current content to only update if it changes - val currentFilters = activeSubscriptions.associate { it.id to it.toJson() } + updateChannelFilters() - updateChannelFilters() + // Makes sure to only send an updated filter when it actually changes. + subscriptions.values.forEach { updatedSubscription -> + val updatedSubscriotionNewFilters = updatedSubscription.typedFilters - // Makes sure to only send an updated filter when it actually changes. - subscriptions.values.forEach { updatedSubscription -> - val updatedSubscriotionNewFilters = updatedSubscription.typedFilters - - if (updatedSubscription.id in currentFilters.keys) { - if (updatedSubscriotionNewFilters == null) { - // was active and is not active anymore, just close. - Client.close(updatedSubscription.id) - } else { - // was active and is still active, check if it has changed. - if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { - Client.close(updatedSubscription.id) - Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters) - } else { - // hasn't changed, does nothing. - Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters) - } + if (updatedSubscription.id in currentFilters.keys) { + if (updatedSubscriotionNewFilters == null) { + // was active and is not active anymore, just close. + Client.close(updatedSubscription.id) + } else { + // was active and is still active, check if it has changed. + if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { + Client.close(updatedSubscription.id) + Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters) + } else { + // hasn't changed, does nothing. + Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters) + } + } + } else { + if (updatedSubscriotionNewFilters == null) { + // was not active and is still not active, does nothing + } else { + // was not active and becomes active, sends the filter. + if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { + Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters) + } + } + } } - } else { - if (updatedSubscriotionNewFilters == null) { - // was not active and is still not active, does nothing - } else { - // was not active and becomes active, sends the filter. - if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { - Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters) - } - } - } } - } - abstract fun updateChannelFilters() -} \ No newline at end of file + abstract fun updateChannelFilters() +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt index 6b09e522ee..4a8830deae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -2,23 +2,23 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter -import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter -object NostrGlobalDataSource: NostrDataSource("GlobalFeed") { - fun createGlobalFilter() = TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), - limit = 200 +object NostrGlobalDataSource : NostrDataSource("GlobalFeed") { + fun createGlobalFilter() = TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), + limit = 200 + ) ) - ) - val globalFeedChannel = requestNewChannel() + val globalFeedChannel = requestNewChannel() - override fun updateChannelFilters() { - globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null } - } -} \ No newline at end of file + override fun updateChannelFilters() { + globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index c4ba610224..6a07042f92 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -3,61 +3,61 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import com.vitorpamplona.amethyst.service.relays.JsonFilter -import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object NostrHomeDataSource: NostrDataSource("HomeFeed") { - lateinit var account: Account +object NostrHomeDataSource : NostrDataSource("HomeFeed") { + lateinit var account: Account - private val cacheListener: (UserState) -> Unit = { - invalidateFilters() - } - - override fun start() { - if (this::account.isInitialized) { - GlobalScope.launch(Dispatchers.Main) { - account.userProfile().live().follows.observeForever(cacheListener) - } - } - super.start() - } - - override fun stop() { - super.stop() - if (this::account.isInitialized) { - GlobalScope.launch(Dispatchers.Main) { - account.userProfile().live().follows.removeObserver(cacheListener) - } - } - } - - fun createFollowAccountsFilter(): TypedFilter { - val follows = account.userProfile().follows - - val followKeys = follows.map { - it.pubkeyHex.substring(0, 6) + private val cacheListener: (UserState) -> Unit = { + invalidateFilters() } - val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6)) + override fun start() { + if (this::account.isInitialized) { + GlobalScope.launch(Dispatchers.Main) { + account.userProfile().live().follows.observeForever(cacheListener) + } + } + super.start() + } - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), - authors = followSet, - limit = 400 - ) - ) - } + override fun stop() { + super.stop() + if (this::account.isInitialized) { + GlobalScope.launch(Dispatchers.Main) { + account.userProfile().live().follows.removeObserver(cacheListener) + } + } + } - val followAccountChannel = requestNewChannel() + fun createFollowAccountsFilter(): TypedFilter { + val follows = account.userProfile().follows - override fun updateChannelFilters() { - followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null } - } -} \ No newline at end of file + val followKeys = follows.map { + it.pubkeyHex.substring(0, 6) + } + + val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6)) + + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = JsonFilter( + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), + authors = followSet, + limit = 400 + ) + ) + } + + val followAccountChannel = requestNewChannel() + + override fun updateChannelFilters() { + followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index 03cd8eb207..63d601b166 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -5,86 +5,86 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter -import com.vitorpamplona.amethyst.service.relays.JsonFilter -import nostr.postr.bechToBytes import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter +import nostr.postr.bechToBytes import nostr.postr.toHex -object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") { - private var searchString: String? = null +object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { + private var searchString: String? = null - private fun createAnythingWithIDFilter(): List? { - val mySearchString = searchString - if (mySearchString == null) { - return null + private fun createAnythingWithIDFilter(): List? { + val mySearchString = searchString + if (mySearchString == null) { + return null + } + + val hexToWatch = try { + if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) { + decodePublicKey(mySearchString).toHex() + } else if (mySearchString.startsWith("note")) { + mySearchString.bechToBytes().toHex() + } else { + mySearchString + } + } catch (e: Exception) { + // Usually when people add an incomplete npub or note. + null + } + + if (hexToWatch == null) { + return null + } + + // downloads all the reactions to a given event. + return listOf( + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + ids = listOfNotNull(hexToWatch) + ) + ), + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(MetadataEvent.kind), + authors = listOfNotNull(hexToWatch) + ) + ), + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(MetadataEvent.kind), + search = mySearchString, + limit = 20 + ) + ), + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind), + search = mySearchString, + limit = 20 + ) + ) + ) } - val hexToWatch = try { - if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) { - decodePublicKey(mySearchString).toHex() - } else if (mySearchString.startsWith("note")) { - mySearchString.bechToBytes().toHex() - } else { - mySearchString - } - } catch (e: Exception) { - // Usually when people add an incomplete npub or note. - null + val searchChannel = requestNewChannel() + + override fun updateChannelFilters() { + searchChannel.typedFilters = createAnythingWithIDFilter() } - if (hexToWatch == null) { - return null + fun search(searchString: String) { + this.searchString = searchString + invalidateFilters() } - // downloads all the reactions to a given event. - return listOf( - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - ids = listOfNotNull(hexToWatch) - ) - ), - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = listOfNotNull(hexToWatch) - ) - ), - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - search = mySearchString, - limit = 20, - ) - ), - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind), - search = mySearchString, - limit = 20 - ) - ) - ) - } - - val searchChannel = requestNewChannel() - - override fun updateChannelFilters() { - searchChannel.typedFilters = createAnythingWithIDFilter() - } - - fun search(searchString: String) { - this.searchString = searchString - invalidateFilters() - } - - fun clear() { - searchString = null - } -} \ No newline at end of file + fun clear() { + searchString = null + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt index 4b4df7907f..037e03dd72 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt @@ -4,66 +4,66 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter -object NostrSingleChannelDataSource: NostrDataSource("SingleChannelFeed") { - private var channelsToWatch = setOf() +object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { + private var channelsToWatch = setOf() - private fun createRepliesAndReactionsFilter(): TypedFilter? { - val reactionsToWatch = channelsToWatch.map { it } + private fun createRepliesAndReactionsFilter(): TypedFilter? { + val reactionsToWatch = channelsToWatch.map { it } - if (reactionsToWatch.isEmpty()) { - return null + if (reactionsToWatch.isEmpty()) { + return null + } + + // downloads all the reactions to a given event. + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = JsonFilter( + kinds = listOf(ChannelMetadataEvent.kind), + tags = mapOf("e" to reactionsToWatch) + ) + ) } - // downloads all the reactions to a given event. - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(ChannelMetadataEvent.kind), - tags = mapOf("e" to reactionsToWatch) - ) - ) - } + fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { + val directEventsToLoad = channelsToWatch + .map { LocalCache.getOrCreateChannel(it) } + .filter { it.notes.isEmpty() } - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { - val directEventsToLoad = channelsToWatch - .map { LocalCache.getOrCreateChannel(it) } - .filter { it.notes.isEmpty() } + val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() - val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() + if (interestedEvents.isEmpty()) { + return null + } - if (interestedEvents.isEmpty()) { - return null + // downloads linked events to this event. + return TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(ChannelCreateEvent.kind), + ids = interestedEvents.toList() + ) + ) } - // downloads linked events to this event. - return TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(ChannelCreateEvent.kind), - ids = interestedEvents.toList() - ) - ) - } + val singleChannelChannel = requestNewChannel() - val singleChannelChannel = requestNewChannel() + override fun updateChannelFilters() { + val reactions = createRepliesAndReactionsFilter() + val missing = createLoadEventsIfNotLoadedFilter() - override fun updateChannelFilters() { - val reactions = createRepliesAndReactionsFilter() - val missing = createLoadEventsIfNotLoadedFilter() + singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null } + } - singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null } - } + fun add(eventId: String) { + channelsToWatch = channelsToWatch.plus(eventId) + invalidateFilters() + } - fun add(eventId: String) { - channelsToWatch = channelsToWatch.plus(eventId) - invalidateFilters() - } - - fun remove(eventId: String) { - channelsToWatch = channelsToWatch.minus(eventId) - invalidateFilters() - } -} \ No newline at end of file + fun remove(eventId: String) { + channelsToWatch = channelsToWatch.minus(eventId) + invalidateFilters() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index d40378425e..440744fe82 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -14,166 +14,172 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter import java.util.Date -import com.vitorpamplona.amethyst.service.relays.JsonFilter -import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { - private var eventsToWatch = setOf() - private var addressesToWatch = setOf() +object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { + private var eventsToWatch = setOf() + private var addressesToWatch = setOf() - private fun createTagToAddressFilter(): List? { - val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch + private fun createTagToAddressFilter(): List? { + val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch - if (addressesToWatch.isEmpty()) { - return null + if (addressesToWatch.isEmpty()) { + return null + } + + val now = Date().time / 1000 + + return addressesToWatch.filter { + val lastTime = it.lastReactionsDownloadTime + lastTime == null || lastTime < (now - 10) + }.mapNotNull { + it.address()?.let { aTag -> + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf( + TextNoteEvent.kind, LongTextNoteEvent.kind, + ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, + LnZapEvent.kind, LnZapRequestEvent.kind, + BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind + ), + tags = mapOf("a" to listOf(aTag.toTag())), + since = it.lastReactionsDownloadTime + ) + ) + } + } } - val now = Date().time / 1000 + private fun createAddressFilter(): List? { + val addressesToWatch = addressesToWatch.filter { it.event == null } - return addressesToWatch.filter { - val lastTime = it.lastReactionsDownloadTime - lastTime == null || lastTime < (now - 10) - }.mapNotNull { - it.address()?.let { aTag -> - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, LongTextNoteEvent.kind, - ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, - LnZapEvent.kind, LnZapRequestEvent.kind, - BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind - ), - tags = mapOf("a" to listOf(aTag.toTag())), - since = it.lastReactionsDownloadTime - ) + if (addressesToWatch.isEmpty()) { + return null + } + + val now = Date().time / 1000 + + return addressesToWatch.filter { + val lastTime = it.lastReactionsDownloadTime + lastTime == null || lastTime < (now - 10) + }.mapNotNull { + it.address()?.let { aTag -> + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(aTag.kind), + tags = mapOf("d" to listOf(aTag.dTag)), + authors = listOf(aTag.pubKeyHex.substring(0, 8)) + ) + ) + } + } + } + + private fun createRepliesAndReactionsFilter(): List? { + val reactionsToWatch = eventsToWatch + + if (reactionsToWatch.isEmpty()) { + return null + } + + val now = Date().time / 1000 + + return reactionsToWatch.filter { + val lastTime = it.lastReactionsDownloadTime + lastTime == null || lastTime < (now - 10) + }.map { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf( + TextNoteEvent.kind, + LongTextNoteEvent.kind, + ReactionEvent.kind, + RepostEvent.kind, + ReportEvent.kind, + LnZapEvent.kind, + LnZapRequestEvent.kind + ), + tags = mapOf("e" to listOf(it.idHex)), + since = it.lastReactionsDownloadTime + ) + ) + } + } + + fun createLoadEventsIfNotLoadedFilter(): List? { + val directEventsToLoad = eventsToWatch + .filter { it.event == null } + + val threadingEventsToLoad = eventsToWatch + .mapNotNull { it.replyTo } + .flatten() + .filter { it !is AddressableNote && it.event == null } + + val interestedEvents = + (directEventsToLoad + threadingEventsToLoad) + .map { it.idHex }.toSet() + + if (interestedEvents.isEmpty()) { + return null + } + + // downloads linked events to this event. + return listOf( + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf( + TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, + ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind + ), + ids = interestedEvents.toList() + ) + ) ) - } - } - } - - private fun createAddressFilter(): List? { - val addressesToWatch = addressesToWatch.filter { it.event == null } - - if (addressesToWatch.isEmpty()) { - return null } - val now = Date().time / 1000 - - return addressesToWatch.filter { - val lastTime = it.lastReactionsDownloadTime - lastTime == null || lastTime < (now - 10) - }.mapNotNull { - it.address()?.let { aTag -> - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(aTag.kind), - tags = mapOf("d" to listOf(aTag.dTag)), - authors = listOf(aTag.pubKeyHex.substring(0,8)) - ) - ) - } - } - } - - private fun createRepliesAndReactionsFilter(): List? { - val reactionsToWatch = eventsToWatch - - if (reactionsToWatch.isEmpty()) { - return null + val singleEventChannel = requestNewChannel { time -> + eventsToWatch.forEach { + it.lastReactionsDownloadTime = time + } + // Many relays operate with limits in the amount of filters. + // As information comes, the filters will be rotated to get more data. + invalidateFilters() } - val now = Date().time / 1000 + override fun updateChannelFilters() { + val reactions = createRepliesAndReactionsFilter() + val missing = createLoadEventsIfNotLoadedFilter() + val addresses = createAddressFilter() + val addressReactions = createTagToAddressFilter() - return reactionsToWatch.filter { - val lastTime = it.lastReactionsDownloadTime - lastTime == null || lastTime < (now - 10) - }.map { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind - ), - tags = mapOf("e" to listOf(it.idHex)), - since = it.lastReactionsDownloadTime - ) - ) - } - } - - fun createLoadEventsIfNotLoadedFilter(): List? { - val directEventsToLoad = eventsToWatch - .filter { it.event == null } - - val threadingEventsToLoad = eventsToWatch - .mapNotNull { it.replyTo } - .flatten() - .filter { it !is AddressableNote && it.event == null } - - val interestedEvents = - (directEventsToLoad + threadingEventsToLoad) - .map { it.idHex }.toSet() - - if (interestedEvents.isEmpty()) { - return null + singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null } } - // downloads linked events to this event. - return listOf( - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, - ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind - ), - ids = interestedEvents.toList() - ) - ) - ) - } - - val singleEventChannel = requestNewChannel { time -> - eventsToWatch.forEach { - it.lastReactionsDownloadTime = time + fun add(eventId: Note) { + eventsToWatch = eventsToWatch.plus(eventId) + invalidateFilters() } - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() - } - override fun updateChannelFilters() { - val reactions = createRepliesAndReactionsFilter() - val missing = createLoadEventsIfNotLoadedFilter() - val addresses = createAddressFilter() - val addressReactions = createTagToAddressFilter() + fun remove(eventId: Note) { + eventsToWatch = eventsToWatch.minus(eventId) + invalidateFilters() + } - singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null } - } + fun addAddress(aTag: Note) { + addressesToWatch = addressesToWatch.plus(aTag) + invalidateFilters() + } - fun add(eventId: Note) { - eventsToWatch = eventsToWatch.plus(eventId) - invalidateFilters() - } - - fun remove(eventId: Note) { - eventsToWatch = eventsToWatch.minus(eventId) - invalidateFilters() - } - - fun addAddress(aTag: Note) { - addressesToWatch = addressesToWatch.plus(aTag) - invalidateFilters() - } - - fun removeAddress(aTag: Note) { - addressesToWatch = addressesToWatch.minus(aTag) - invalidateFilters() - } -} \ No newline at end of file + fun removeAddress(aTag: Note) { + addressesToWatch = addressesToWatch.minus(aTag) + invalidateFilters() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index 328344a689..3685ed9fd1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -1,65 +1,65 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.JsonFilter -import com.vitorpamplona.amethyst.service.model.MetadataEvent +import com.vitorpamplona.amethyst.service.relays.TypedFilter -object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") { - var usersToWatch = setOf() +object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { + var usersToWatch = setOf() - fun createUserFilter(): List? { - if (usersToWatch.isEmpty()) return null + fun createUserFilter(): List? { + if (usersToWatch.isEmpty()) return null - return usersToWatch.filter { it.info?.latestMetadata == null }.map { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = listOf(it.pubkeyHex), - limit = 1 - ) - ) + return usersToWatch.filter { it.info?.latestMetadata == null }.map { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(MetadataEvent.kind), + authors = listOf(it.pubkeyHex), + limit = 1 + ) + ) + } } - } - fun createUserReportFilter(): List? { - if (usersToWatch.isEmpty()) return null + fun createUserReportFilter(): List? { + if (usersToWatch.isEmpty()) return null - return usersToWatch.map { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(ReportEvent.kind), - tags = mapOf("p" to listOf(it.pubkeyHex)), - since = it.latestReportTime - ) - ) + return usersToWatch.map { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(ReportEvent.kind), + tags = mapOf("p" to listOf(it.pubkeyHex)), + since = it.latestReportTime + ) + ) + } } - } - val userChannel = requestNewChannel(){ - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() - } + val userChannel = requestNewChannel() { + // Many relays operate with limits in the amount of filters. + // As information comes, the filters will be rotated to get more data. + invalidateFilters() + } - val userChannelOnce = requestNewChannel() + val userChannelOnce = requestNewChannel() - override fun updateChannelFilters() { - userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null } - userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null } - } + override fun updateChannelFilters() { + userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null } + userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null } + } - fun add(user: User) { - usersToWatch = usersToWatch.plus(user) - invalidateFilters() - } + fun add(user: User) { + usersToWatch = usersToWatch.plus(user) + invalidateFilters() + } - fun remove(user: User) { - usersToWatch = usersToWatch.minus(user) - invalidateFilters() - } -} \ No newline at end of file + fun remove(user: User) { + usersToWatch = usersToWatch.minus(user) + invalidateFilters() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index 52383ac539..e16f9e70f3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -2,42 +2,42 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter -object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") { - private var eventToWatch: String? = null +object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { + private var eventToWatch: String? = null - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { - val threadToLoad = eventToWatch ?: return null + fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { + val threadToLoad = eventToWatch ?: return null - val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad) - .filter { it.event == null } - .map { it.idHex } - .toSet() - .ifEmpty { null } ?: return null + val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad) + .filter { it.event == null } + .map { it.idHex } + .toSet() + .ifEmpty { null } ?: return null - return TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - ids = eventsToLoad.map { it.substring(0, 8) } - ) - ) - } + return TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + ids = eventsToLoad.map { it.substring(0, 8) } + ) + ) + } - val loadEventsChannel = requestNewChannel(){ - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() - } + val loadEventsChannel = requestNewChannel() { + // Many relays operate with limits in the amount of filters. + // As information comes, the filters will be rotated to get more data. + invalidateFilters() + } - override fun updateChannelFilters() { - loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null } - } + override fun updateChannelFilters() { + loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null } + } - fun loadThread(noteId: String?) { - eventToWatch = noteId + fun loadThread(noteId: String?) { + eventToWatch = noteId - invalidateFilters() - } -} \ No newline at end of file + invalidateFilters() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 26684b34f2..39c85a97be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -4,114 +4,114 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent +import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.TypedFilter -import com.vitorpamplona.amethyst.service.relays.JsonFilter -import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter -object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") { - var user: User? = null +object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { + var user: User? = null - fun loadUserProfile(userId: String?) { - if (userId != null) { - user = LocalCache.getOrCreateUser(userId) - } else { - user = null + fun loadUserProfile(userId: String?) { + if (userId != null) { + user = LocalCache.getOrCreateUser(userId) + } else { + user = null + } + + resetFilters() } - resetFilters() - } + fun createUserInfoFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(MetadataEvent.kind), + authors = listOf(it.pubkeyHex), + limit = 1 + ) + ) + } - fun createUserInfoFilter() = user?.let { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = listOf(it.pubkeyHex), - limit = 1 - ) - ) - } + fun createUserPostsFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), + authors = listOf(it.pubkeyHex), + limit = 200 + ) + ) + } - fun createUserPostsFilter() = user?.let { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), - authors = listOf(it.pubkeyHex), - limit = 200 - ) - ) - } + fun createUserReceivedZapsFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(LnZapEvent.kind), + tags = mapOf("p" to listOf(it.pubkeyHex)) + ) + ) + } - fun createUserReceivedZapsFilter() = user?.let { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(LnZapEvent.kind), - tags = mapOf("p" to listOf(it.pubkeyHex)) - ) - ) - } + fun createFollowFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(ContactListEvent.kind), + authors = listOf(it.pubkeyHex), + limit = 1 + ) + ) + } - fun createFollowFilter() = user?.let { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(ContactListEvent.kind), - authors = listOf(it.pubkeyHex), - limit = 1 - ) - ) - } + fun createFollowersFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(ContactListEvent.kind), + tags = mapOf("p" to listOf(it.pubkeyHex)) + ) + ) + } - fun createFollowersFilter() = user?.let { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(ContactListEvent.kind), - tags = mapOf("p" to listOf(it.pubkeyHex)) - ) - ) - } + fun createAcceptedAwardsFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(BadgeProfilesEvent.kind), + authors = listOf(it.pubkeyHex), + limit = 1 + ) + ) + } - fun createAcceptedAwardsFilter() = user?.let { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(BadgeProfilesEvent.kind), - tags = mapOf("p" to listOf(it.pubkeyHex)), - limit = 1 - ) - ) - } + fun createReceivedAwardsFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(BadgeAwardEvent.kind), + tags = mapOf("p" to listOf(it.pubkeyHex)), + limit = 20 + ) + ) + } - fun createReceivedAwardsFilter() = user?.let { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(BadgeAwardEvent.kind), - tags = mapOf("p" to listOf(it.pubkeyHex)), - limit = 20 - ) - ) - } + val userInfoChannel = requestNewChannel() - val userInfoChannel = requestNewChannel() - - override fun updateChannelFilters() { - userInfoChannel.typedFilters = listOfNotNull( - createUserInfoFilter(), - createUserPostsFilter(), - createFollowFilter(), - createFollowersFilter(), - createUserReceivedZapsFilter(), - createAcceptedAwardsFilter(), - createReceivedAwardsFilter() - ).ifEmpty { null } - } -} \ No newline at end of file + override fun updateChannelFilters() { + userInfoChannel.typedFilters = listOfNotNull( + createUserInfoFilter(), + createUserPostsFilter(), + createFollowFilter(), + createFollowersFilter(), + createUserReceivedZapsFilter(), + createAcceptedAwardsFilter(), + createReceivedAwardsFilter() + ).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index be22a9c335..aa3bc4f32f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -13,123 +13,122 @@ import com.linkedin.urls.detection.UrlDetectorOptions import java.util.regex.Pattern class ResultOrError( - var result: String?, - var sourceLang: String?, - var targetLang: String?, - var error: Exception? + var result: String?, + var sourceLang: String?, + var targetLang: String?, + var error: Exception? ) object LanguageTranslatorService { - private val languageIdentification = LanguageIdentification.getClient() - val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b") + private val languageIdentification = LanguageIdentification.getClient() + val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b") - private val translators = - object : LruCache(10) { - override fun create(options: TranslatorOptions): Translator { - return Translation.getClient(options) - } + private val translators = + object : LruCache(10) { + override fun create(options: TranslatorOptions): Translator { + return Translation.getClient(options) + } - override fun entryRemoved( - evicted: Boolean, - key: TranslatorOptions, - oldValue: Translator, - newValue: Translator? - ) { - oldValue.close() - } - } - - fun identifyLanguage(text: String): Task { - return languageIdentification.identifyLanguage(text) - } - - fun translate(text: String, source: String, target: String): Task { - val sourceLangCode = TranslateLanguage.fromLanguageTag(source) - val targetLangCode = TranslateLanguage.fromLanguageTag(target) - - if (sourceLangCode == null || targetLangCode == null) { - return Tasks.forCanceled() - } - - val options = TranslatorOptions.Builder() - .setSourceLanguage(sourceLangCode) - .setTargetLanguage(targetLangCode) - .build() - - val translator = translators[options] - - return translator.downloadModelIfNeeded().onSuccessTask { - val tasks = mutableListOf>() - - val dict = lnDictionary(text) + urlDictionary(text) - - for (paragraph in encodeDictionary(text, dict).split("\n")) { - tasks.add(translator.translate(paragraph)) - } - - Tasks.whenAll(tasks).continueWith { - val results: MutableList = ArrayList() - for (task in tasks) { - var fixedText = task.result.replace("# [","#[") // fixes tags that always return with a space - results.add(decodeDictionary(fixedText, dict)) + override fun entryRemoved( + evicted: Boolean, + key: TranslatorOptions, + oldValue: Translator, + newValue: Translator? + ) { + oldValue.close() + } } - ResultOrError(results.joinToString("\n"), source, target, null) - } + + fun identifyLanguage(text: String): Task { + return languageIdentification.identifyLanguage(text) } - } - private fun encodeDictionary(text: String, dict: Map): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.value, pair.key, true) + fun translate(text: String, source: String, target: String): Task { + val sourceLangCode = TranslateLanguage.fromLanguageTag(source) + val targetLangCode = TranslateLanguage.fromLanguageTag(target) + + if (sourceLangCode == null || targetLangCode == null) { + return Tasks.forCanceled() + } + + val options = TranslatorOptions.Builder() + .setSourceLanguage(sourceLangCode) + .setTargetLanguage(targetLangCode) + .build() + + val translator = translators[options] + + return translator.downloadModelIfNeeded().onSuccessTask { + val tasks = mutableListOf>() + + val dict = lnDictionary(text) + urlDictionary(text) + + for (paragraph in encodeDictionary(text, dict).split("\n")) { + tasks.add(translator.translate(paragraph)) + } + + Tasks.whenAll(tasks).continueWith { + val results: MutableList = ArrayList() + for (task in tasks) { + var fixedText = task.result.replace("# [", "#[") // fixes tags that always return with a space + results.add(decodeDictionary(fixedText, dict)) + } + ResultOrError(results.joinToString("\n"), source, target, null) + } + } } - return newText - } - private fun decodeDictionary(text: String, dict: Map): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.key, pair.value, true) + private fun encodeDictionary(text: String, dict: Map): String { + var newText = text + for (pair in dict) { + newText = newText.replace(pair.value, pair.key, true) + } + return newText } - return newText - } - private fun lnDictionary(text: String): Map { - val matcher = lnRegex.matcher(text) - val returningList = mutableMapOf() - val counter = 0 - while (matcher.find()) { - try { - val lnInvoice = matcher.group() - val short = "Amethystlnindexer${counter}" - returningList.put(short, lnInvoice) - } catch (e: Exception) { - - } + private fun decodeDictionary(text: String, dict: Map): String { + var newText = text + for (pair in dict) { + newText = newText.replace(pair.key, pair.value, true) + } + return newText } - return returningList - } - private fun urlDictionary(text: String): Map { - val parser = UrlDetector(text, UrlDetectorOptions.Default) - val urlsInText = parser.detect() - - val counter = 0 - - return urlsInText.filter { !it.originalUrl.contains(",") || !it.originalUrl.contains("。") }.associate { - "Amethysturlindexer${counter}" to it.originalUrl + private fun lnDictionary(text: String): Map { + val matcher = lnRegex.matcher(text) + val returningList = mutableMapOf() + val counter = 0 + while (matcher.find()) { + try { + val lnInvoice = matcher.group() + val short = "Amethystlnindexer$counter" + returningList.put(short, lnInvoice) + } catch (e: Exception) { + } + } + return returningList } - } - fun autoTranslate(text: String, dontTranslateFrom: Set, translateTo: String): Task { - return identifyLanguage(text).onSuccessTask { - if (it == translateTo) { - Tasks.forCanceled() - } else if (it != "und" && !dontTranslateFrom.contains(it)) { - translate(text, it, translateTo) - } else { - Tasks.forCanceled() - } + private fun urlDictionary(text: String): Map { + val parser = UrlDetector(text, UrlDetectorOptions.Default) + val urlsInText = parser.detect() + + val counter = 0 + + return urlsInText.filter { !it.originalUrl.contains(",") || !it.originalUrl.contains("。") }.associate { + "Amethysturlindexer$counter" to it.originalUrl + } } - } -} \ No newline at end of file + + fun autoTranslate(text: String, dontTranslateFrom: Set, translateTo: String): Task { + return identifyLanguage(text).onSuccessTask { + if (it == translateTo) { + Tasks.forCanceled() + } else if (it != "und" && !dontTranslateFrom.contains(it)) { + translate(text, it, translateTo) + } else { + Tasks.forCanceled() + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 7d3fe774de..1a23718056 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -1,7 +1,6 @@ package com.vitorpamplona.amethyst.service.lnurl import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import java.net.URLEncoder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -13,151 +12,160 @@ import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import java.net.URLEncoder class LightningAddressResolver { - val client = OkHttpClient.Builder().build() + val client = OkHttpClient.Builder().build() - fun assembleUrl(lnaddress: String): String? { - val parts = lnaddress.split("@") + fun assembleUrl(lnaddress: String): String? { + val parts = lnaddress.split("@") - if (parts.size == 2) { - return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" - } - - if (lnaddress.lowercase().startsWith("lnurl")) { - return try { - String(Bech32.decodeBytes(lnaddress, false).second) - } catch (e: Exception) { - null - } - } - - return null - } - - fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - val scope = CoroutineScope(Job() + Dispatchers.IO) - scope.launch { - fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError) - } - } - - private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - val url = assembleUrl(lnaddress) - - if (url == null) { - onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup") - return - } - - withContext(Dispatchers.IO) { - val request: Request = Request.Builder().url(url).build() - - client.newCall(request).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - response.use { - if (it.isSuccessful) - onSuccess(it.body.string()) - else - onError("Could not resolve ${lnaddress}. Error: ${it.code}. Check if the server up and if the lightning address ${lnaddress} is correct") - } + if (parts.size == 2) { + return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" } - override fun onFailure(call: Call, e: java.io.IOException) { - onError("Could not resolve ${url}. Check if the server up and if the lightning address ${lnaddress} is correct") - e.printStackTrace() - } - }) - } - } - - fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - val scope = CoroutineScope(Job() + Dispatchers.IO) - scope.launch { - fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError) - } - } - - private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - withContext(Dispatchers.IO) { - val encodedMessage = URLEncoder.encode(message, "utf-8") - - val urlBinder = if (lnCallback.contains("?")) "&" else "?" - var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" - - if (nostrRequest != null) { - val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") - url += "&nostr=$encodedNostrRequest" - } - - val request: Request = Request.Builder().url(url).build() - - client.newCall(request).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - response.use { - if (it.isSuccessful) - onSuccess(response.body.string()) - else - onError("Could not fetch invoice from $lnCallback") - } - } - - override fun onFailure(call: Call, e: java.io.IOException) { - onError("Could not fetch an invoice from $lnCallback. Message ${e.message}") - e.printStackTrace() - } - }) - } - } - - fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - fetchLightningAddressJson(lnaddress, - onSuccess = { - onSuccess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32)) - }, - onError = onError - ) - } - - fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - val mapper = jacksonObjectMapper() - - fetchLightningAddressJson(lnaddress, - onSuccess = { - val lnurlp = try { - mapper.readTree(it) - } catch (t: Throwable) { - onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup") - null - } - - val callback = lnurlp?.get("callback")?.asText() - - if (callback == null) { - onError("Callback URL not found in the User's lightning address server configuration") - } - - val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false - - callback?.let { callback -> - fetchLightningInvoice(callback, milliSats, message, if (allowsNostr) nostrRequest else null, - onSuccess = { - val lnInvoice = try { - mapper.readTree(it) - } catch (t: Throwable) { - onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup") + if (lnaddress.lowercase().startsWith("lnurl")) { + return try { + String(Bech32.decodeBytes(lnaddress, false).second) + } catch (e: Exception) { null - } + } + } - lnInvoice?.get("pr")?.asText()?.let { pr -> - onSuccess(pr) - } ?: onError("Invoice Not Created (element pr not found in the resulting JSON)") + return null + } + + fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError) + } + } + + private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + val url = assembleUrl(lnaddress) + + if (url == null) { + onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup") + return + } + + withContext(Dispatchers.IO) { + val request: Request = Request.Builder().url(url).build() + + client.newCall(request).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + response.use { + if (it.isSuccessful) { + onSuccess(it.body.string()) + } else { + onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct") + } + } + } + + override fun onFailure(call: Call, e: java.io.IOException) { + onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct") + e.printStackTrace() + } + }) + } + } + + fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError) + } + } + + private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + withContext(Dispatchers.IO) { + val encodedMessage = URLEncoder.encode(message, "utf-8") + + val urlBinder = if (lnCallback.contains("?")) "&" else "?" + var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" + + if (nostrRequest != null) { + val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") + url += "&nostr=$encodedNostrRequest" + } + + val request: Request = Request.Builder().url(url).build() + + client.newCall(request).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + response.use { + if (it.isSuccessful) { + onSuccess(response.body.string()) + } else { + onError("Could not fetch invoice from $lnCallback") + } + } + } + + override fun onFailure(call: Call, e: java.io.IOException) { + onError("Could not fetch an invoice from $lnCallback. Message ${e.message}") + e.printStackTrace() + } + }) + } + } + + fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + fetchLightningAddressJson( + lnaddress, + onSuccess = { + onSuccess(Bech32.encodeBytes("lnurl", it.toByteArray(), Bech32.Encoding.Bech32)) }, onError = onError - ) - } - }, - onError = onError - ) - } + ) + } + + fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + val mapper = jacksonObjectMapper() + + fetchLightningAddressJson( + lnaddress, + onSuccess = { + val lnurlp = try { + mapper.readTree(it) + } catch (t: Throwable) { + onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup") + null + } + + val callback = lnurlp?.get("callback")?.asText() + + if (callback == null) { + onError("Callback URL not found in the User's lightning address server configuration") + } + + val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false + + callback?.let { callback -> + fetchLightningInvoice( + callback, + milliSats, + message, + if (allowsNostr) nostrRequest else null, + onSuccess = { + val lnInvoice = try { + mapper.readTree(it) + } catch (t: Throwable) { + onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup") + null + } + + lnInvoice?.get("pr")?.asText()?.let { pr -> + onSuccess(pr) + } ?: onError("Invoice Not Created (element pr not found in the resulting JSON)") + }, + onError = onError + ) + } + }, + onError = onError + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnInvoiceUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnInvoiceUtil.kt index 18f730270b..c2864d57b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnInvoiceUtil.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnInvoiceUtil.kt @@ -6,151 +6,150 @@ import java.util.regex.Pattern /** based on litecoinj */ object LnInvoiceUtil { - private val invoicePattern = Pattern.compile("lnbc((?\\d+)(?[munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE) + private val invoicePattern = Pattern.compile("lnbc((?\\d+)(?[munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE) - /** The Bech32 character set for encoding. */ - private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + /** The Bech32 character set for encoding. */ + private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - /** The Bech32 character set for decoding. */ - private val CHARSET_REV = byteArrayOf( - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - 15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1, - -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, - 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1, - -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, - 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1 - ) + /** The Bech32 character set for decoding. */ + private val CHARSET_REV = byteArrayOf( + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + 15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1, + -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, + 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1, + -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, + 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1 + ) - - /** Find the polynomial with value coefficients mod the generator as 30-bit. */ - private fun polymod(values: ByteArray): Int { - var c = 1 - for (v_i in values) { - val c0 = c ushr 25 and 0xff - c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff) - if (c0 and 1 != 0) c = c xor 0x3b6a57b2 - if (c0 and 2 != 0) c = c xor 0x26508e6d - if (c0 and 4 != 0) c = c xor 0x1ea119fa - if (c0 and 8 != 0) c = c xor 0x3d4233dd - if (c0 and 16 != 0) c = c xor 0x2a1462b3 - } - return c - } - - /** Expand a HRP for use in checksum computation. */ - private fun expandHrp(hrp: String): ByteArray { - val hrpLength = hrp.length - val ret = ByteArray(hrpLength * 2 + 1) - for (i in 0 until hrpLength) { - val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII - ret[i] = (c ushr 5 and 0x07).toByte() - ret[i + hrpLength + 1] = (c and 0x1f).toByte() - } - ret[hrpLength] = 0 - return ret - } - - /** Verify a checksum. */ - private fun verifyChecksum(hrp: String, values: ByteArray): Boolean { - val hrpExpanded: ByteArray = expandHrp(hrp) - val combined = ByteArray(hrpExpanded.size + values.size) - System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size) - System.arraycopy(values, 0, combined, hrpExpanded.size, values.size) - return polymod(combined) == 1 - } - - class AddressFormatException(message: String): Exception(message) { - - } - - fun decodeUnlimitedLength(invoice: String): Boolean { - var lower = false - var upper = false - for (i in 0 until invoice.length) { - val c = invoice[i] - if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i") - if (c in 'a'..'z') { - if (upper) throw AddressFormatException("Invalid character: $c, pos: $i") - lower = true - } - if (c in 'A'..'Z') { - if (lower) throw AddressFormatException("Invalid character: $c, pos: $i") - upper = true - } - } - val pos = invoice.lastIndexOf('1') - if (pos < 1) throw AddressFormatException("Missing human-readable part") - val dataPartLength = invoice.length - 1 - pos - if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength") - val values = ByteArray(dataPartLength) - for (i in 0 until dataPartLength) { - val c = invoice[i + pos + 1] - if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1)) - values[i] = CHARSET_REV.get(c.code) - } - val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT) - if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum") - return true - } - - /** - * Parses invoice amount according to - * https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part - * @return invoice amount in bitcoins, zero if the invoice has no amount - * @throws RuntimeException if invoice format is incorrect - */ - fun getAmount(invoice: String): BigDecimal { - try { - decodeUnlimitedLength(invoice) // checksum must match - } catch (e: AddressFormatException) { - throw IllegalArgumentException("Cannot decode invoice", e) + /** Find the polynomial with value coefficients mod the generator as 30-bit. */ + private fun polymod(values: ByteArray): Int { + var c = 1 + for (v_i in values) { + val c0 = c ushr 25 and 0xff + c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff) + if (c0 and 1 != 0) c = c xor 0x3b6a57b2 + if (c0 and 2 != 0) c = c xor 0x26508e6d + if (c0 and 4 != 0) c = c xor 0x1ea119fa + if (c0 and 8 != 0) c = c xor 0x3d4233dd + if (c0 and 16 != 0) c = c xor 0x2a1462b3 + } + return c } - val matcher = invoicePattern.matcher(invoice) - require(matcher.matches()) { "Failed to match HRP pattern" } - val amountGroup = matcher.group("amount") - val multiplierGroup = matcher.group("multiplier") - if (amountGroup == null) { - return BigDecimal.ZERO + /** Expand a HRP for use in checksum computation. */ + private fun expandHrp(hrp: String): ByteArray { + val hrpLength = hrp.length + val ret = ByteArray(hrpLength * 2 + 1) + for (i in 0 until hrpLength) { + val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII + ret[i] = (c ushr 5 and 0x07).toByte() + ret[i + hrpLength + 1] = (c and 0x1f).toByte() + } + ret[hrpLength] = 0 + return ret } - val amount = BigDecimal(amountGroup) - if (multiplierGroup == null) { - return amount - } - require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" } - return amount.multiply(multiplier(multiplierGroup)) - } - fun getAmountInSats(invoice: String): BigDecimal { - return getAmount(invoice).multiply(BigDecimal(100000000)) - } - - private fun multiplier(multiplier: String): BigDecimal { - return when (multiplier.lowercase()) { - "m" -> BigDecimal("0.001") - "u" -> BigDecimal("0.000001") - "n" -> BigDecimal("0.000000001") - "p" -> BigDecimal("0.000000000001") - else -> throw IllegalArgumentException("Invalid multiplier: $multiplier") + /** Verify a checksum. */ + private fun verifyChecksum(hrp: String, values: ByteArray): Boolean { + val hrpExpanded: ByteArray = expandHrp(hrp) + val combined = ByteArray(hrpExpanded.size + values.size) + System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size) + System.arraycopy(values, 0, combined, hrpExpanded.size, values.size) + return polymod(combined) == 1 } - } - /** - * Finds LN invoice in the provided input string and returns it. - * For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx" - * It will only return the first invoice found in the input. - * - * @return the invoice if it was found. null for null input or if no invoice is found - */ - fun findInvoice(input: String?): String? { - if (input == null) { - return null + class AddressFormatException(message: String) : Exception(message) + + fun decodeUnlimitedLength(invoice: String): Boolean { + var lower = false + var upper = false + for (i in 0 until invoice.length) { + val c = invoice[i] + if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i") + if (c in 'a'..'z') { + if (upper) throw AddressFormatException("Invalid character: $c, pos: $i") + lower = true + } + if (c in 'A'..'Z') { + if (lower) throw AddressFormatException("Invalid character: $c, pos: $i") + upper = true + } + } + val pos = invoice.lastIndexOf('1') + if (pos < 1) throw AddressFormatException("Missing human-readable part") + val dataPartLength = invoice.length - 1 - pos + if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength") + val values = ByteArray(dataPartLength) + for (i in 0 until dataPartLength) { + val c = invoice[i + pos + 1] + if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1)) + values[i] = CHARSET_REV.get(c.code) + } + val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT) + if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum") + return true + } + + /** + * Parses invoice amount according to + * https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part + * @return invoice amount in bitcoins, zero if the invoice has no amount + * @throws RuntimeException if invoice format is incorrect + */ + fun getAmount(invoice: String): BigDecimal { + try { + decodeUnlimitedLength(invoice) // checksum must match + } catch (e: AddressFormatException) { + throw IllegalArgumentException("Cannot decode invoice", e) + } + + val matcher = invoicePattern.matcher(invoice) + require(matcher.matches()) { "Failed to match HRP pattern" } + val amountGroup = matcher.group("amount") + val multiplierGroup = matcher.group("multiplier") + if (amountGroup == null) { + return BigDecimal.ZERO + } + val amount = BigDecimal(amountGroup) + if (multiplierGroup == null) { + return amount + } + require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" } + return amount.multiply(multiplier(multiplierGroup)) + } + + fun getAmountInSats(invoice: String): BigDecimal { + return getAmount(invoice).multiply(BigDecimal(100000000)) + } + + private fun multiplier(multiplier: String): BigDecimal { + return when (multiplier.lowercase()) { + "m" -> BigDecimal("0.001") + "u" -> BigDecimal("0.000001") + "n" -> BigDecimal("0.000000001") + "p" -> BigDecimal("0.000000000001") + else -> throw IllegalArgumentException("Invalid multiplier: $multiplier") + } + } + + /** + * Finds LN invoice in the provided input string and returns it. + * For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx" + * It will only return the first invoice found in the input. + * + * @return the invoice if it was found. null for null input or if no invoice is found + */ + fun findInvoice(input: String?): String? { + if (input == null) { + return null + } + val matcher = invoicePattern.matcher(input) + return if (matcher.find()) { + matcher.group() + } else { + null + } } - val matcher = invoicePattern.matcher(input) - return if (matcher.find()) { - matcher.group() - } else null - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt index 4a8ae03748..27a39b4d8f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt @@ -3,45 +3,54 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey -import com.vitorpamplona.amethyst.service.NIP19TLVTypes -import com.vitorpamplona.amethyst.service.parseTLV -import com.vitorpamplona.amethyst.service.toInt32 +import com.vitorpamplona.amethyst.service.nip19.Tlv import fr.acinq.secp256k1.Hex import nostr.postr.Bech32 import nostr.postr.bechToBytes import nostr.postr.toByteArray -data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String) { +data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) { fun toTag() = "$kind:$pubKeyHex:$dTag" fun toNAddr(): String { val kind = kind.toByteArray() - val addr = pubKeyHex.toByteArray() + val author = pubKeyHex.toByteArray() val dTag = dTag.toByteArray(Charsets.UTF_8) + val relay = relay?.toByteArray(Charsets.UTF_8) - val fullArray = - byteArrayOf(NIP19TLVTypes.SPECIAL.id, dTag.size.toByte()) + dTag + - byteArrayOf(NIP19TLVTypes.AUTHOR.id, addr.size.toByte()) + addr + - byteArrayOf(NIP19TLVTypes.KIND.id, kind.size.toByte()) + kind + var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, dTag.size.toByte()) + dTag + + if (relay != null) { + fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay + } + + fullArray = fullArray + + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author + + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind return Bech32.encodeBytes(hrp = "naddr", fullArray, Bech32.Encoding.Bech32) } companion object { - fun parse(address: String): ATag? { - return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) - parseNAddr(address) - else - parseAtag(address) + fun isATag(key: String): Boolean { + return key.startsWith("naddr1") || key.contains(":") } - fun parseAtag(atag: String): ATag? { + fun parse(address: String, relay: String?): ATag? { + return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) { + parseNAddr(address) + } else { + parseAtag(address, relay) + } + } + + fun parseAtag(atag: String, relay: String?): ATag? { return try { val parts = atag.split(":") - Hex.decode(parts[1]) - ATag(parts[0].toInt(), parts[1], parts[2]) + Hex.decode(parts[1]) + ATag(parts[0].toInt(), parts[1], parts[2], relay) } catch (t: Throwable) { - Log.w("ATag", "Error parsing A Tag: ${atag}: ${t.message}") + Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}") null } } @@ -51,22 +60,22 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String) { val key = naddr.removePrefix("nostr:") if (key.startsWith("naddr")) { - val tlv = parseTLV(key.bechToBytes()) - val d = tlv.get(NIP19TLVTypes.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8) ?: "" - val relay = tlv.get(NIP19TLVTypes.RELAY.id)?.get(0)?.toString(Charsets.UTF_8) - val author = tlv.get(NIP19TLVTypes.AUTHOR.id)?.get(0)?.toHexKey() - val kind = tlv.get(NIP19TLVTypes.KIND.id)?.get(0)?.let { toInt32(it) } + val tlv = Tlv.parse(key.bechToBytes()) + val d = tlv.get(Tlv.Type.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8) ?: "" + val relay = tlv.get(Tlv.Type.RELAY.id)?.get(0)?.toString(Charsets.UTF_8) + val author = tlv.get(Tlv.Type.AUTHOR.id)?.get(0)?.toHexKey() + val kind = tlv.get(Tlv.Type.KIND.id)?.get(0)?.let { Tlv.toInt32(it) } - if (kind != null && author != null) - return ATag(kind, author, d) + if (kind != null && author != null) { + return ATag(kind, author, d, relay) + } } - } catch (e: Throwable) { - Log.w( "ATag", "Issue trying to Decode NIP19 ${this}: ${e.message}") - //e.printStackTrace() + Log.w("ATag", "Issue trying to Decode NIP19 $this: ${e.message}") + // e.printStackTrace() } return null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt index b0a3693a74..6269f63c01 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt @@ -1,9 +1,6 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey -import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date -import nostr.postr.Utils class BadgeAwardEvent( id: HexKey, @@ -12,9 +9,14 @@ class BadgeAwardEvent( tags: List>, content: String, sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } + fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } companion object { const val kind = 8 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt index d3572d84d6..e7c9748126 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt @@ -1,9 +1,6 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey -import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date -import nostr.postr.Utils class BadgeDefinitionEvent( id: HexKey, @@ -12,9 +9,9 @@ class BadgeDefinitionEvent( tags: List>, content: String, sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag()) + fun address() = ATag(kind, pubKey, dTag(), null) fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull() fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt index 9a6ce84a9a..4995c413ba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt @@ -9,12 +9,17 @@ class BadgeProfilesEvent( tags: List>, content: String, sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } + fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag()) + fun address() = ATag(kind, pubKey, dTag(), null) companion object { const val kind = 30008 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt index 6a1d93f935..bd907792b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt @@ -3,45 +3,46 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date -class ChannelCreateEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun channelInfo() = try { - MetadataEvent.gson.fromJson(content, ChannelData::class.java) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelData(null, null, null) - } - - companion object { - const val kind = 40 - - fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent { - val content = try { - if (channelInfo != null) - gson.toJson(channelInfo) - else - "" - } catch (t: Throwable) { - Log.e("ChannelCreateEvent", "Couldn't parse channel information", t) - "" - } - - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - val tags = emptyList>() - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) +class ChannelCreateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun channelInfo() = try { + MetadataEvent.gson.fromJson(content, ChannelData::class.java) + } catch (e: Exception) { + Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) + ChannelData(null, null, null) } - } - data class ChannelData(var name: String?, var about: String?, var picture: String?) -} \ No newline at end of file + companion object { + const val kind = 40 + + fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent { + val content = try { + if (channelInfo != null) { + gson.toJson(channelInfo) + } else { + "" + } + } catch (t: Throwable) { + Log.e("ChannelCreateEvent", "Couldn't parse channel information", t) + "" + } + + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = emptyList>() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } + + data class ChannelData(var name: String?, var about: String?, var picture: String?) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt index 4f8b57f188..1a84818545 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt @@ -2,33 +2,33 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date -class ChannelHideMessageEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } +class ChannelHideMessageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - companion object { - const val kind = 43 + companion object { + const val kind = 43 - fun create(reason: String, messagesToHide: List?, mentions: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent { - val content = reason - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - val tags = - messagesToHide?.map { - listOf("e", it) - } ?: emptyList() + fun create(reason: String, messagesToHide: List?, mentions: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent { + val content = reason + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = + messagesToHide?.map { + listOf("e", it) + } ?: emptyList() - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt index 411a258d80..76a05c6cd5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt @@ -2,41 +2,41 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date -class ChannelMessageEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +class ChannelMessageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) - fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) } - fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) + fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) } + fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - companion object { - const val kind = 42 + companion object { + const val kind = 42 - fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent { - val content = message - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - val tags = mutableListOf( - listOf("e", channel, "", "root") - ) - replyTos?.forEach { - tags.add(listOf("e", it)) - } - mentions?.forEach { - tags.add(listOf("p", it)) - } + fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent { + val content = message + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = mutableListOf( + listOf("e", channel, "", "root") + ) + replyTos?.forEach { + tags.add(listOf("e", it)) + } + mentions?.forEach { + tags.add(listOf("p", it)) + } - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return ChannelMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt index d84ea2ab81..6382e588cd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt @@ -3,41 +3,42 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date -class ChannelMetadataEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) - fun channelInfo() = - try { - MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelCreateEvent.ChannelData(null, null, null) +class ChannelMetadataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) + fun channelInfo() = + try { + MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java) + } catch (e: Exception) { + Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) + ChannelCreateEvent.ChannelData(null, null, null) + } + + companion object { + const val kind = 41 + + fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent { + val content = + if (newChannelInfo != null) { + gson.toJson(newChannelInfo) + } else { + "" + } + + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = listOf(listOf("e", originalChannelIdHex, "", "root")) + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } } - - companion object { - const val kind = 41 - - fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent { - val content = - if (newChannelInfo != null) - gson.toJson(newChannelInfo) - else - "" - - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - val tags = listOf( listOf("e", originalChannelIdHex, "", "root") ) - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt index 6b12e96bf0..79988b4de8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt @@ -2,34 +2,34 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date -class ChannelMuteUserEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +class ChannelMuteUserEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - companion object { - const val kind = 44 + companion object { + const val kind = 44 - fun create(reason: String, usersToMute: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent { - val content = reason - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - val tags = - usersToMute?.map { - listOf("p", it) - } ?: emptyList() + fun create(reason: String, usersToMute: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent { + val content = reason + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = + usersToMute?.map { + listOf("p", it) + } ?: emptyList() - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return ChannelMuteUserEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelMuteUserEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt index 502fb75510..67aa25bdcf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt @@ -4,8 +4,8 @@ import android.util.Log import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date data class Contact(val pubKeyHex: String, val relayUri: String?) @@ -16,7 +16,7 @@ class ContactListEvent( tags: List>, content: String, sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun follows() = try { tags.filter { it[0] == "p" }.map { Contact(it[1], it.getOrNull(2)) } } catch (e: Exception) { @@ -25,10 +25,11 @@ class ContactListEvent( } fun relayUse() = try { - if (content.isNotEmpty()) - gson.fromJson(content, object: TypeToken>() {}.type) - else + if (content.isNotEmpty()) { + gson.fromJson(content, object : TypeToken>() {}.type) + } else { null + } } catch (e: Exception) { Log.e("ContactListEvent", "can't parse content as relay lists: $tags", e) null @@ -38,16 +39,18 @@ class ContactListEvent( const val kind = 3 fun create(follows: List, relayUse: Map?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent { - val content = if (relayUse != null) + val content = if (relayUse != null) { gson.toJson(relayUse) - else + } else { "" + } val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = follows.map { - if (it.relayUri != null) + if (it.relayUri != null) { listOf("p", it.pubKeyHex, it.relayUri) - else + } else { listOf("p", it.pubKeyHex) + } } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privateKey) @@ -56,4 +59,4 @@ class ContactListEvent( } data class ReadWrite(val read: Boolean, val write: Boolean) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/DeletionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/DeletionEvent.kt index a29cd62613..4d7170f61b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/DeletionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/DeletionEvent.kt @@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date class DeletionEvent( id: HexKey, @@ -12,7 +12,7 @@ class DeletionEvent( tags: List>, content: String, sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun deleteEvents() = tags.map { it[1] } companion object { @@ -27,4 +27,4 @@ class DeletionEvent( return DeletionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index a9189aaf01..70b237e2d1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -1,25 +1,16 @@ package com.vitorpamplona.amethyst.service.model -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.JsonArray -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer +import com.google.gson.* import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Secp256k1 -import java.lang.reflect.Type -import java.security.MessageDigest -import java.util.Date import nostr.postr.Utils import nostr.postr.toHex +import java.lang.reflect.Type +import java.security.MessageDigest +import java.util.* open class Event( val id: HexKey, @@ -29,18 +20,51 @@ open class Event( val tags: List>, val content: String, val sig: HexKey -) { - fun toJson(): String = gson.toJson(this) +) : EventInterface { + override fun id(): HexKey = id - fun generateId(): String { - val rawEvent = listOf( - 0, - pubKey, - createdAt, - kind, - tags, - content - ) + override fun pubKey(): HexKey = pubKey + + override fun createdAt(): Long = createdAt + + override fun kind(): Int = kind + + override fun tags(): List> = tags + + override fun content(): String = content + + override fun sig(): HexKey = sig + + override fun toJson(): String = gson.toJson(this) + + /** + * Checks if the ID is correct and then if the pubKey's secret key signed the event. + */ + override fun checkSignature() { + if (!id.contentEquals(generateId())) { + throw Exception( + """|Unexpected ID. + | Event: ${toJson()} + | Actual ID: $id + | Generated: ${generateId()} + """.trimIndent() + ) + } + if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) { + throw Exception("""Bad signature!""") + } + } + + override fun hasValidSignature(): Boolean { + if (!id.contentEquals(generateId())) { + return false + } + + return secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) + } + + private fun generateId(): String { + val rawEvent = listOf(0, pubKey, createdAt, kind, tags, content) // GSON decided to hardcode these replacements. // They break Nostr's hash check. @@ -53,35 +77,7 @@ open class Event( return sha256.digest(rawEventJson.toByteArray()).toHexKey() } - /** - * Checks if the ID is correct and then if the pubKey's secret key signed the event. - */ - fun checkSignature() { - if (!id.contentEquals(generateId())) { - throw Exception( - """|Unexpected ID. - | Event: ${toJson()} - | Actual ID: ${id} - | Generated: ${generateId()}""".trimIndent() - ) - } - if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) { - throw Exception("""Bad signature!""") - } - } - - fun hasValidSignature(): Boolean { - if (!id.contentEquals(generateId())) { - return false - } - if (!Secp256k1.get().verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) { - return false - } - - return true - } - - class EventDeserializer : JsonDeserializer { + private class EventDeserializer : JsonDeserializer { override fun deserialize( json: JsonElement, typeOfT: Type?, @@ -94,7 +90,7 @@ open class Event( createdAt = jsonObject.get("created_at").asLong, kind = jsonObject.get("kind").asInt, tags = jsonObject.get("tags").asJsonArray.map { - it.asJsonArray.map { s -> s.asString } + it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString } }, content = jsonObject.get("content").asString, sig = jsonObject.get("sig").asString @@ -102,7 +98,7 @@ open class Event( } } - class EventSerializer : JsonSerializer { + private class EventSerializer : JsonSerializer { override fun serialize( src: Event, typeOfSrc: Type?, @@ -113,22 +109,27 @@ open class Event( addProperty("pubkey", src.pubKey) addProperty("created_at", src.createdAt) addProperty("kind", src.kind) - add("tags", JsonArray().also { jsonTags -> - src.tags.forEach { tag -> - jsonTags.add(JsonArray().also { jsonTagElement -> - tag.forEach { tagElement -> - jsonTagElement.add(tagElement) - } - }) + add( + "tags", + JsonArray().also { jsonTags -> + src.tags.forEach { tag -> + jsonTags.add( + JsonArray().also { jsonTagElement -> + tag.forEach { tagElement -> + jsonTagElement.add(tagElement) + } + } + ) + } } - }) + ) addProperty("content", src.content) addProperty("sig", src.sig) } } } - class ByteArrayDeserializer : JsonDeserializer { + private class ByteArrayDeserializer : JsonDeserializer { override fun deserialize( json: JsonElement, typeOfT: Type?, @@ -136,7 +137,7 @@ open class Event( ): ByteArray = Hex.decode(json.asString) } - class ByteArraySerializer : JsonSerializer { + private class ByteArraySerializer : JsonSerializer { override fun serialize( src: ByteArray, typeOfSrc: Type?, @@ -206,4 +207,4 @@ open class Event( return Event(id.toHexKey(), pubKey, createdAt, kind, tags, content, sig) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt new file mode 100644 index 0000000000..acc44c1fd8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt @@ -0,0 +1,25 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey + +interface EventInterface { + fun id(): HexKey + + fun pubKey(): HexKey + + fun createdAt(): Long + + fun kind(): Int + + fun tags(): List> + + fun content(): String + + fun sig(): HexKey + + fun toJson(): String + + fun checkSignature() + + fun hasValidSignature(): Boolean +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt index 6b43b627b3..b8845b8d54 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -1,40 +1,66 @@ package com.vitorpamplona.amethyst.service.model +import android.util.Log import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.relays.Client +import java.math.BigDecimal -class LnZapEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +class LnZapEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + override fun zappedPost() = tags + .filter { it.firstOrNull() == "e" } + .mapNotNull { it.getOrNull(1) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } + override fun zappedAuthor() = tags + .filter { it.firstOrNull() == "p" } + .mapNotNull { it.getOrNull(1) } - fun lnInvoice() = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - fun preimage() = tags.filter { it.firstOrNull() == "preimage" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + override fun taggedAddresses(): List = tags + .filter { it.firstOrNull() == "a" } + .mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) - fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } - // Keeps this as a field because it's a heavier function used everywhere. - val amount = lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } - - fun containedPost() = try { - description()?.let { - fromJson(it, Client.lenient) + override fun amount(): BigDecimal? { + return lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } } - } catch (e: Exception) { - null - } - companion object { - const val kind = 9735 - } -} \ No newline at end of file + // Keeps this as a field because it's a heavier function used everywhere. + val amount by lazy { + lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } + } + + override fun containedPost(): Event? = try { + description()?.let { + fromJson(it, Client.lenient) + } + } catch (e: Exception) { + Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) + null + } + + private fun lnInvoice(): String? = tags + .filter { it.firstOrNull() == "bolt11" } + .mapNotNull { it.getOrNull(1) } + .firstOrNull() + + private fun description(): String? = tags + .filter { it.firstOrNull() == "description" } + .mapNotNull { it.getOrNull(1) } + .firstOrNull() + + companion object { + const val kind = 9735 + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt new file mode 100644 index 0000000000..cc95d34b32 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt @@ -0,0 +1,16 @@ +package com.vitorpamplona.amethyst.service.model + +import java.math.BigDecimal + +interface LnZapEventInterface : EventInterface { + + fun zappedPost(): List + + fun zappedAuthor(): List + + fun taggedAddresses(): List + + fun amount(): BigDecimal? + + fun containedPost(): Event? +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index e17c6e6723..75ce41ca29 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -2,54 +2,58 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils -import nostr.postr.toHex +import java.util.Date -class LnZapRequestEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } +class LnZapRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) - companion object { - const val kind = 9734 - - fun create(originalNote: Event, relays: Set, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent { - val content = "" - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - var tags = listOf( - listOf("e", originalNote.id), - listOf("p", originalNote.pubKey), - listOf("relays") + relays - ) - if (originalNote is LongTextNoteEvent) { - tags = tags + listOf( listOf("a", originalNote.address().toTag()) ) - } - - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + if (aTagValue != null) ATag.parse(aTagValue, relay) else null } - fun create(userHex: String, relays: Set, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent { - val content = "" - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - val tags = listOf( - listOf("p", userHex), - listOf("relays") + relays - ) - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + companion object { + const val kind = 9734 + + fun create(originalNote: EventInterface, relays: Set, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent { + val content = "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + var tags = listOf( + listOf("e", originalNote.id()), + listOf("p", originalNote.pubKey()), + listOf("relays") + relays + ) + if (originalNote is LongTextNoteEvent) { + tags = tags + listOf(listOf("a", originalNote.address().toTag())) + } + + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + + fun create(userHex: String, relays: Set, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent { + val content = "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = listOf( + listOf("p", userHex), + listOf("relays") + relays + ) + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } } - } } /* @@ -84,4 +88,4 @@ class LnZapRequestEvent ( ] ] } -*/ \ No newline at end of file +*/ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt index 14fd66d47f..f0ff6c3e75 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt @@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date class LongTextNoteEvent( id: HexKey, @@ -12,12 +12,18 @@ class LongTextNoteEvent( tags: List>, content: String, sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag()) + fun address() = ATag(kind, pubKey, dTag(), null) fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt index bd4c4c6a64..312d3ca439 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt @@ -4,14 +4,15 @@ import android.util.Log import com.google.gson.Gson import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date data class ContactMetaData( val name: String, val picture: String, val about: String, - val nip05: String?) + val nip05: String? +) class MetadataEvent( id: HexKey, @@ -20,7 +21,7 @@ class MetadataEvent( tags: List>, content: String, sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun contactMetaData() = try { gson.fromJson(content, ContactMetaData::class.java) } catch (e: Exception) { @@ -45,4 +46,4 @@ class MetadataEvent( return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt index d0f5111f1d..89e1d98e67 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt @@ -4,9 +4,9 @@ import android.util.Log import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import fr.acinq.secp256k1.Hex -import java.util.Date import nostr.postr.Utils import nostr.postr.toHex +import java.util.Date class PrivateDmEvent( id: HexKey, @@ -48,7 +48,6 @@ class PrivateDmEvent( } } - companion object { const val kind = 4 @@ -57,7 +56,8 @@ class PrivateDmEvent( fun create( recipientPubKey: ByteArray, msg: String, - replyTos: List? = null, mentions: List? = null, + replyTos: List? = null, + mentions: List? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000, publishedRecipientPubKey: ByteArray? = null, @@ -66,7 +66,8 @@ class PrivateDmEvent( val content = Utils.encrypt( if (advertiseNip18) { nip18Advertisement } else { "" } + msg, privateKey, - recipientPubKey) + recipientPubKey + ) val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = mutableListOf>() publishedRecipientPubKey?.let { @@ -83,4 +84,4 @@ class PrivateDmEvent( return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt index c01972758d..75c3eee5b8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt @@ -2,45 +2,49 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils -import nostr.postr.toHex +import java.util.Date -class ReactionEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +class ReactionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } + fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) - companion object { - const val kind = 7 - - fun createWarning(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { - return create("\u26A0\uFE0F", originalNote, privateKey, createdAt) + if (aTagValue != null) ATag.parse(aTagValue, relay) else null } - fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { - return create("+", originalNote, privateKey, createdAt) + companion object { + const val kind = 7 + + fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { + return create("\u26A0\uFE0F", originalNote, privateKey, createdAt) + } + + fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { + return create("+", originalNote, privateKey, createdAt) + } + + fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + + var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey())) + if (originalNote is LongTextNoteEvent) { + tags = tags + listOf(listOf("a", originalNote.address().toTag())) + } + + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } } - - fun create(content: String, originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - - var tags = listOf( listOf("e", originalNote.id), listOf("p", originalNote.pubKey)) - if (originalNote is LongTextNoteEvent) { - tags = tags + listOf( listOf("a", originalNote.address().toTag()) ) - } - - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RecommendRelayEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RecommendRelayEvent.kt index a2ef2eaec4..117931b999 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RecommendRelayEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RecommendRelayEvent.kt @@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils import java.net.URI import java.util.Date -import nostr.postr.Utils class RecommendRelayEvent( id: HexKey, @@ -14,13 +14,13 @@ class RecommendRelayEvent( content: String, sig: HexKey, val lenient: Boolean = false -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun relay() = if (lenient) + fun relay() = if (lenient) { URI.create(content.trim()) - else + } else { URI.create(content) - + } companion object { const val kind = 2 @@ -34,4 +34,4 @@ class RecommendRelayEvent( return RecommendRelayEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt index 6e10f5b5e0..4b2021c260 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt @@ -2,94 +2,98 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils -import nostr.postr.toHex +import java.util.Date data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType) // NIP 56 event. -class ReportEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +class ReportEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - private fun defaultReportType(): ReportType { - // Works with old and new structures for report. - var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull() - if (reportType == null) { - reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull() - } - if (reportType == null) { - reportType = ReportType.SPAM - } - return reportType - } - - fun reportedPost() = tags - .filter { it.firstOrNull() == "e" && it.getOrNull(1) != null } - .map { - ReportedKey( - it[1], - it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType() - ) + private fun defaultReportType(): ReportType { + // Works with old and new structures for report. + var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull() + if (reportType == null) { + reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull() + } + if (reportType == null) { + reportType = ReportType.SPAM + } + return reportType } - fun reportedAuthor() = tags - .filter { it.firstOrNull() == "p" && it.getOrNull(1) != null } - .map { - ReportedKey( - it[1], - it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType() - ) + fun reportedPost() = tags + .filter { it.firstOrNull() == "e" && it.getOrNull(1) != null } + .map { + ReportedKey( + it[1], + it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType() + ) + } + + fun reportedAuthor() = tags + .filter { it.firstOrNull() == "p" && it.getOrNull(1) != null } + .map { + ReportedKey( + it[1], + it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType() + ) + } + + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } + companion object { + const val kind = 1984 - companion object { - const val kind = 1984 + fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent { + val content = "" - fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent { - val content = "" + val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase()) + val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase()) - val reportPostTag = listOf("e", reportedPost.id, type.name.lowercase()) - val reportAuthorTag = listOf("p", reportedPost.pubKey, type.name.lowercase()) + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + var tags: List> = listOf(reportPostTag, reportAuthorTag) - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - var tags:List> = listOf(reportPostTag, reportAuthorTag) + if (reportedPost is LongTextNoteEvent) { + tags = tags + listOf(listOf("a", reportedPost.address().toTag())) + } - if (reportedPost is LongTextNoteEvent) { - tags = tags + listOf( listOf("a", reportedPost.address().toTag()) ) - } + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent { + val content = "" + + val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase()) + + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags: List> = listOf(reportAuthorTag) + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } } - fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent { - val content = "" - - val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase()) - - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - val tags:List> = listOf(reportAuthorTag) - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + enum class ReportType() { + EXPLICIT, // Not used anymore. + ILLEGAL, + SPAM, + IMPERSONATION, + NUDITY, + PROFANITY } - } - - enum class ReportType() { - EXPLICIT, // Not used anymore. - ILLEGAL, - SPAM, - IMPERSONATION, - NUDITY, - PROFANITY, - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt index 5a9513c21a..ce13a6e5ab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt @@ -3,48 +3,52 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.service.relays.Client -import java.util.Date import nostr.postr.Utils +import java.util.Date -class RepostEvent ( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +class RepostEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) - fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } - - fun containedPost() = try { - fromJson(content, Client.lenient) - } catch (e: Exception) { - null - } - - companion object { - const val kind = 6 - - fun create(boostedPost: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent { - val content = boostedPost.toJson() - - val replyToPost = listOf("e", boostedPost.id) - val replyToAuthor = listOf("p", boostedPost.pubKey) - - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - var tags:List> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor)) - - if (boostedPost is LongTextNoteEvent) { - tags = tags + listOf( listOf("a", boostedPost.address().toTag()) ) - } - - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return RepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + if (aTagValue != null) ATag.parse(aTagValue, relay) else null } - } -} \ No newline at end of file + + fun containedPost() = try { + fromJson(content, Client.lenient) + } catch (e: Exception) { + null + } + + companion object { + const val kind = 6 + + fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent { + val content = boostedPost.toJson() + + val replyToPost = listOf("e", boostedPost.id()) + val replyToAuthor = listOf("p", boostedPost.pubKey()) + + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + var tags: List> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor)) + + if (boostedPost is LongTextNoteEvent) { + tags = tags + listOf(listOf("a", boostedPost.address().toTag())) + } + + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return RepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index 63839802b5..faa883fb3e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey -import java.util.Date import nostr.postr.Utils +import java.util.Date class TextNoteEvent( id: HexKey, @@ -12,9 +12,15 @@ class TextNoteEvent( tags: List>, content: String, sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } + fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } companion object { @@ -37,4 +43,4 @@ class TextNoteEvent( return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt new file mode 100644 index 0000000000..a7e68b29cc --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt @@ -0,0 +1,18 @@ +package com.vitorpamplona.amethyst.service.model.zaps + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.LnZapEventInterface + +object UserZaps { + fun forProfileFeed(zaps: Map?): List> { + if (zaps == null) return emptyList() + + return ( + zaps + .filter { it.value != null } + .toList() + .sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() } + .reversed() + ) as List> + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt new file mode 100644 index 0000000000..796936331e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt @@ -0,0 +1,104 @@ +package com.vitorpamplona.amethyst.service.nip19 + +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.bechToBytes + +object Nip19 { + enum class Type { + USER, NOTE, RELAY, ADDRESS + } + + data class Return(val type: Type, val hex: String, val relay: String? = null) + + fun uriToRoute(uri: String?): Return? { + try { + val key = uri?.removePrefix("nostr:") ?: return null + + val bytes = key.bechToBytes() + if (key.startsWith("npub")) { + return npub(bytes) + } else if (key.startsWith("note")) { + return note(bytes) + } else if (key.startsWith("nprofile")) { + return nprofile(bytes) + } else if (key.startsWith("nevent")) { + return nevent(bytes) + } else if (key.startsWith("nrelay")) { + return nrelay(bytes) + } else if (key.startsWith("naddr")) { + return naddr(bytes) + } + } catch (e: Throwable) { + println("Issue trying to Decode NIP19 $uri: ${e.message}") + } + + return null + } + + private fun npub(bytes: ByteArray): Return { + return Return(Type.USER, bytes.toHexKey()) + } + + private fun note(bytes: ByteArray): Return { + return Return(Type.NOTE, bytes.toHexKey()) + } + + private fun nprofile(bytes: ByteArray): Return? { + val tlv = Tlv.parse(bytes) + + val hex = tlv.get(Tlv.Type.SPECIAL.id) + ?.get(0) + ?.toHexKey() ?: return null + + val relay = tlv.get(Tlv.Type.RELAY.id) + ?.get(0) + ?.toString(Charsets.UTF_8) + + return Return(Type.USER, hex, relay) + } + + private fun nevent(bytes: ByteArray): Return? { + val tlv = Tlv.parse(bytes) + + val hex = tlv.get(Tlv.Type.SPECIAL.id) + ?.get(0) + ?.toHexKey() ?: return null + + val relay = tlv.get(Tlv.Type.RELAY.id) + ?.get(0) + ?.toString(Charsets.UTF_8) + + return Return(Type.USER, hex, relay) + } + + private fun nrelay(bytes: ByteArray): Return? { + val relayUrl = Tlv.parse(bytes) + .get(Tlv.Type.SPECIAL.id) + ?.get(0) + ?.toString(Charsets.UTF_8) ?: return null + + return Return(Type.RELAY, relayUrl) + } + + private fun naddr(bytes: ByteArray): Return? { + val tlv = Tlv.parse(bytes) + + val d = tlv.get(Tlv.Type.SPECIAL.id) + ?.get(0) + ?.toString(Charsets.UTF_8) ?: return null + + val relay = tlv.get(Tlv.Type.RELAY.id) + ?.get(0) + ?.toString(Charsets.UTF_8) + + val author = tlv.get(Tlv.Type.AUTHOR.id) + ?.get(0) + ?.toHexKey() + + val kind = tlv.get(Tlv.Type.KIND.id) + ?.get(0) + ?.let { Tlv.toInt32(it) } + + return Return(Type.ADDRESS, "$kind:$author:$d", relay) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Tlv.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Tlv.kt new file mode 100644 index 0000000000..ef285d6274 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Tlv.kt @@ -0,0 +1,36 @@ +package com.vitorpamplona.amethyst.service.nip19 + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +object Tlv { + enum class Type(val id: Byte) { + SPECIAL(0), + RELAY(1), + AUTHOR(2), + KIND(3); + } + + fun toInt32(bytes: ByteArray): Int { + require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" } + return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int + } + + fun parse(data: ByteArray): Map> { + val result = mutableMapOf>() + var rest = data + while (rest.isNotEmpty()) { + val t = rest[0] + val l = rest[1] + val v = rest.sliceArray(IntRange(2, (2 + l) - 1)) + rest = rest.sliceArray(IntRange(2 + l, rest.size - 1)) + if (v.size < l) continue + + if (!result.containsKey(t)) { + result[t] = mutableListOf() + } + result[t]?.add(v) + } + return result + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index 776a670fbd..76d098c743 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -1,17 +1,18 @@ package com.vitorpamplona.amethyst.service.relays -import java.util.UUID +import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.EventInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import com.vitorpamplona.amethyst.service.model.Event +import java.util.UUID /** * 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 { +object Client : RelayPool.Listener { /** * Lenient mode: * @@ -62,7 +63,7 @@ object Client: RelayPool.Listener { RelayPool.sendFilterOnlyIfDisconnected() } - fun send(signedEvent: Event) { + fun send(signedEvent: EventInterface) { RelayPool.send(signedEvent) } @@ -146,4 +147,4 @@ object Client: RelayPool.Listener { */ open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt index 05ca9e3872..5632e9da59 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt @@ -3,52 +3,52 @@ package com.vitorpamplona.amethyst.service.relays import com.vitorpamplona.amethyst.model.RelaySetupInfo object Constants { - val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) - val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) - val activeTypesSearch = setOf(FeedType.SEARCH) + val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) + val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) + val activeTypesSearch = setOf(FeedType.SEARCH) - fun convertDefaultRelays(): Array { - return defaultRelays.map { - Relay(it.url, it.read, it.write, it.feedTypes) - }.toTypedArray() - } + fun convertDefaultRelays(): Array { + return defaultRelays.map { + Relay(it.url, it.read, it.write, it.feedTypes) + }.toTypedArray() + } - val defaultRelays = arrayOf( - // Free relays - RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes), + val defaultRelays = arrayOf( + // Free relays + RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes), + RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes), + RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes), + RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), + RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes), + RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes), + RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes), + RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes), + RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes), - // Less Reliable - //NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes), - //NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes = activeTypes), - //NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, feedTypes = activeTypes), - //NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = activeTypes), - //NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, feedTypes = activeTypes), - //NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = activeTypes), - //NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes = activeTypes), - //NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes), + // Less Reliable + // NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes), - // Paid relays - RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats), + // Paid relays + RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats), + RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), + RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats), + RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), + RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats), + RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats), + RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), + RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats), + RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats), - // Supporting NIP-50 - RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch), - ) + // Supporting NIP-50 + RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch) + ) - val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch) -} \ No newline at end of file + val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt index 205ff76f0e..54ea763c7b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt @@ -21,7 +21,7 @@ class JsonFilter( val since: Long? = null, val until: Long? = null, val limit: Int? = null, - val search: String? = null, + val search: String? = null ) : Filter, Serializable { fun toJson(): String { val jsonObject = JsonObject() @@ -61,8 +61,9 @@ class JsonFilter( tags?.forEach { tag -> if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false } - if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) + if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) { return false + } return true } @@ -113,10 +114,18 @@ class JsonFilter( } return JsonFilter( ids = if (json.has("ids")) json.getAsJsonArray("ids").map { it.asString } else null, - authors = if (json.has("authors")) json.getAsJsonArray("authors") - .map { it.asString } else null, - kinds = if (json.has("kinds")) json.getAsJsonArray("kinds") - .map { it.asInt } else null, + authors = if (json.has("authors")) { + json.getAsJsonArray("authors") + .map { it.asString } + } else { + null + }, + kinds = if (json.has("kinds")) { + json.getAsJsonArray("kinds") + .map { it.asInt } + } else { + null + }, tags = json .entrySet() .filter { it.key.startsWith("#") } @@ -131,4 +140,4 @@ class JsonFilter( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 0804d07d79..3b132324fe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -2,13 +2,14 @@ package com.vitorpamplona.amethyst.service.relays import android.util.Log import com.google.gson.JsonElement -import java.util.Date import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.EventInterface import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.util.Date enum class FeedType { FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH @@ -18,12 +19,12 @@ class Relay( var url: String, var read: Boolean = true, var write: Boolean = true, - var activeTypes: Set = FeedType.values().toSet(), + var activeTypes: Set = FeedType.values().toSet() ) { private val httpClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) - .build(); + .build() private var listeners = setOf() private var socket: WebSocket? = null @@ -76,26 +77,26 @@ class Relay( val channel = msg[1].asString when (type) { "EVENT" -> { - //Log.w("Relay", "Relay onEVENT $url, $channel") + // Log.w("Relay", "Relay onEVENT $url, $channel") eventDownloadCounter++ val event = Event.fromJson(msg[2], Client.lenient) listeners.forEach { it.onEvent(this@Relay, channel, event) } } "EOSE" -> listeners.forEach { - //Log.w("Relay", "Relay onEOSE $url, $channel") + // Log.w("Relay", "Relay onEOSE $url, $channel") it.onRelayStateChange(this@Relay, Type.EOSE, channel) } "NOTICE" -> listeners.forEach { - //Log.w("Relay", "Relay onNotice $url, $channel") + // Log.w("Relay", "Relay onNotice $url, $channel") // "channel" being the second string in the string array ... it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - //Log.w("Relay", "Relay onOK $url, $channel") + // Log.w("Relay", "Relay onOK $url, $channel") it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString) } else -> listeners.forEach { - //Log.w("Relay", "Relay something else $url, $channel") + // Log.w("Relay", "Relay something else $url, $channel") it.onError( this@Relay, channel, @@ -112,11 +113,13 @@ class Relay( } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - listeners.forEach { it.onRelayStateChange( - this@Relay, - Type.DISCONNECTING, - null - ) } + listeners.forEach { + it.onRelayStateChange( + this@Relay, + Type.DISCONNECTING, + null + ) + } } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { @@ -135,10 +138,10 @@ class Relay( isReady = false closingTime = Date().time / 1000 - Log.w("Relay", "Relay onFailure $url, ${response?.message} ${response}") + Log.w("Relay", "Relay onFailure $url, ${response?.message} $response") t.printStackTrace() listeners.forEach { - it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t)) + it.onError(this@Relay, "", Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t)) } } } @@ -154,7 +157,7 @@ class Relay( } fun disconnect() { - //httpClient.dispatcher.executorService.shutdown() + // httpClient.dispatcher.executorService.shutdown() closingTime = Date().time / 1000 socket?.close(1000, "Normal close") socket = null @@ -169,7 +172,7 @@ class Relay( if (filters.isNotEmpty()) { val request = """["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]""" - //println("FILTERSSENT ${url} ${request}") + // println("FILTERSSENT ${url} ${request}") socket?.send(request) } } @@ -187,37 +190,40 @@ class Relay( if (socket == null) { // waits 60 seconds to reconnect after disconnected. if (Date().time / 1000 > closingTime + 60) { - //println("sendfilter Only if Disconnected ${url} ") + // println("sendfilter Only if Disconnected ${url} ") requestAndWatch() } } } - fun send(signedEvent: Event) { + fun send(signedEvent: EventInterface) { if (write) { socket?.send("""["EVENT",${signedEvent.toJson()}]""") eventUploadCounter++ } } - fun close(subscriptionId: String){ + fun close(subscriptionId: String) { socket?.send("""["CLOSE","$subscriptionId"]""") } fun isSameRelayConfig(other: Relay): Boolean { - return url == other.url - && write == other.write - && read == other.read - && activeTypes == other.activeTypes + return url == other.url && + write == other.write && + read == other.read && + activeTypes == other.activeTypes } enum class Type { // Websocket connected CONNECT, + // Websocket disconnecting DISCONNECTING, + // Websocket disconnected DISCONNECT, + // End Of Stored Events EOSE } @@ -231,6 +237,7 @@ class Relay( fun onError(relay: Relay, subscriptionId: String, error: Error) fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) + /** * Connected to or disconnected from a relay * diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index bb9952a821..e1d5bb2155 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -1,16 +1,17 @@ package com.vitorpamplona.amethyst.service.relays import androidx.lifecycle.LiveData +import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.EventInterface import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import com.vitorpamplona.amethyst.service.model.Event /** * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. */ -object RelayPool: Relay.Listener { +object RelayPool : Relay.Listener { val scope = CoroutineScope(Job() + Dispatchers.IO) @@ -29,8 +30,8 @@ object RelayPool: Relay.Listener { return relays.firstOrNull() { it.url == url } } - fun loadRelays(relayList: List){ - if (!relayList.isNullOrEmpty()){ + fun loadRelays(relayList: List) { + if (!relayList.isNullOrEmpty()) { relayList.forEach { addRelay(it) } } else { Constants.convertDefaultRelays().forEach { addRelay(it) } @@ -54,11 +55,11 @@ object RelayPool: Relay.Listener { relays.forEach { it.sendFilterOnlyIfDisconnected() } } - fun send(signedEvent: Event) { + fun send(signedEvent: EventInterface) { relays.forEach { it.send(signedEvent) } } - fun close(subscriptionId: String){ + fun close(subscriptionId: String) { relays.forEach { it.close(subscriptionId) } } @@ -122,10 +123,10 @@ object RelayPool: Relay.Listener { } } -class RelayPoolLiveData(val relays: RelayPool): LiveData(RelayPoolState(relays)) { +class RelayPoolLiveData(val relays: RelayPool) : LiveData(RelayPoolState(relays)) { fun refresh() { postValue(RelayPoolState(relays)) } } -class RelayPoolState(val relays: RelayPool) \ No newline at end of file +class RelayPoolState(val relays: RelayPool) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt index 6465fa9e60..fc1f01013f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt @@ -5,26 +5,26 @@ import com.google.gson.JsonArray import com.google.gson.JsonObject import java.util.UUID -data class Subscription ( - val id: String = UUID.randomUUID().toString().substring(0,4), - val onEOSE: ((Long) -> Unit)? = null +data class Subscription( + val id: String = UUID.randomUUID().toString().substring(0, 4), + val onEOSE: ((Long) -> Unit)? = null ) { - var typedFilters: List? = null // Inactive when null + var typedFilters: List? = null // Inactive when null - fun updateEOSE(l: Long) { - onEOSE?.let { it(l) } - } - - fun toJson(): String { - return GsonBuilder().create().toJson(toJsonObject()) - } - - fun toJsonObject(): JsonObject { - val jsonObject = JsonObject() - jsonObject.addProperty("id", id) - typedFilters?.run { - jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } }) + fun updateEOSE(l: Long) { + onEOSE?.let { it(l) } } - return jsonObject - } -} \ No newline at end of file + + fun toJson(): String { + return GsonBuilder().create().toJson(toJsonObject()) + } + + fun toJsonObject(): JsonObject { + val jsonObject = JsonObject() + jsonObject.addProperty("id", id) + typedFilters?.run { + jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } }) + } + return jsonObject + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt index dcce6b393e..d52a1d11c7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt @@ -3,53 +3,52 @@ package com.vitorpamplona.amethyst.service.relays import com.google.gson.GsonBuilder import com.google.gson.JsonArray import com.google.gson.JsonObject -import com.vitorpamplona.amethyst.service.relays.JsonFilter class TypedFilter( - val types: Set, - val filter: JsonFilter + val types: Set, + val filter: JsonFilter ) { - fun toJson(): String { - return GsonBuilder().create().toJson(toJsonObject()) - } + fun toJson(): String { + return GsonBuilder().create().toJson(toJsonObject()) + } - fun toJsonObject(): JsonObject { - val jsonObject = JsonObject() - jsonObject.add("types", typesToJson(types)) - jsonObject.add("filter", filterToJson(filter)) - return jsonObject - } + fun toJsonObject(): JsonObject { + val jsonObject = JsonObject() + jsonObject.add("types", typesToJson(types)) + jsonObject.add("filter", filterToJson(filter)) + return jsonObject + } - fun typesToJson(types: Set): JsonArray { - return JsonArray().apply { types.forEach { add(it.name.lowercase()) } } - } + fun typesToJson(types: Set): JsonArray { + return JsonArray().apply { types.forEach { add(it.name.lowercase()) } } + } - fun filterToJson(filter: JsonFilter): JsonObject { - val jsonObject = JsonObject() - filter.ids?.run { - jsonObject.add("ids", JsonArray().apply { filter.ids?.forEach { add(it) } }) + fun filterToJson(filter: JsonFilter): JsonObject { + val jsonObject = JsonObject() + filter.ids?.run { + jsonObject.add("ids", JsonArray().apply { filter.ids?.forEach { add(it) } }) + } + filter.authors?.run { + jsonObject.add("authors", JsonArray().apply { filter.authors?.forEach { add(it) } }) + } + filter.kinds?.run { + jsonObject.add("kinds", JsonArray().apply { filter.kinds?.forEach { add(it) } }) + } + filter.tags?.run { + entries.forEach { kv -> + jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } }) + } + } + filter.since?.run { + jsonObject.addProperty("since", filter.since) + } + filter.until?.run { + jsonObject.addProperty("until", filter.until) + } + filter.limit?.run { + jsonObject.addProperty("limit", filter.limit) + } + return jsonObject } - filter.authors?.run { - jsonObject.add("authors", JsonArray().apply { filter.authors?.forEach { add(it) } }) - } - filter.kinds?.run { - jsonObject.add("kinds", JsonArray().apply { filter.kinds?.forEach { add(it) } }) - } - filter.tags?.run { - entries.forEach { kv -> - jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } }) - } - } - filter.since?.run { - jsonObject.addProperty("since", filter.since) - } - filter.until?.run { - jsonObject.addProperty("until", filter.until) - } - filter.limit?.run { - jsonObject.addProperty("limit", filter.limit) - } - return jsonObject - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index cd622f88cd..9ea473a801 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -16,73 +16,72 @@ import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager -import com.vitorpamplona.amethyst.service.Nip19 +import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme class MainActivity : FragmentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - val nip19 = Nip19().uriToRoute(intent?.data?.toString()) - val startingPage = when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - else -> null - } - - Coil.setImageLoader { - ImageLoader.Builder(this).components { - if (SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) + val nip19 = Nip19.uriToRoute(intent?.data?.toString()) + val startingPage = when (nip19?.type) { + Nip19.Type.USER -> "User/${nip19.hex}" + Nip19.Type.NOTE -> "Note/${nip19.hex}" + else -> null } - add(SvgDecoder.Factory()) - } //.logger(DebugLogger()) - .respectCacheHeaders(false) - .build() - } - setContent { - AmethystTheme { - // A surface container using the 'background' color from the theme - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - - val accountStateViewModel: AccountStateViewModel = viewModel { - AccountStateViewModel(LocalPreferences(applicationContext)) - } - - AccountScreen(accountStateViewModel, startingPage) + Coil.setImageLoader { + ImageLoader.Builder(this).components { + if (SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(SvgDecoder.Factory()) + } // .logger(DebugLogger()) + .respectCacheHeaders(false) + .build() } - } + + setContent { + AmethystTheme { + // A surface container using the 'background' color from the theme + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + val accountStateViewModel: AccountStateViewModel = viewModel { + AccountStateViewModel(LocalPreferences(applicationContext)) + } + + AccountScreen(accountStateViewModel, startingPage) + } + } + } + + Client.lenient = true } - Client.lenient = true - } + override fun onResume() { + super.onResume() - override fun onResume() { - super.onResume() - - // Only starts after login - ServiceManager.start() - } + // Only starts after login + ServiceManager.start() + } - override fun onPause() { - ServiceManager.pause() + override fun onPause() { + ServiceManager.pause() - super.onPause() - } + super.onPause() + } - /** - * Release memory when the UI becomes hidden or when system resources become low. - * @param level the memory-related event that was raised. - */ - override fun onTrimMemory(level: Int) { - super.onTrimMemory(level) - println("Trim Memory $level") - ServiceManager.cleanUp() - } + /** + * Release memory when the UI becomes hidden or when system resources become low. + * @param level the memory-related event that was raised. + */ + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + println("Trim Memory $level") + ServiceManager.cleanUp() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt index 5e1b209907..386ab20bf2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt @@ -15,7 +15,6 @@ import okio.IOException import okio.sink import java.io.File - object ImageSaver { /** * Saves the image to the gallery. @@ -27,7 +26,7 @@ object ImageSaver { url: String, context: Context, onSuccess: () -> Any?, - onError: (Throwable) -> Any?, + onError: (Throwable) -> Any? ) { val client = OkHttpClient.Builder().build() @@ -56,13 +55,13 @@ object ImageSaver { displayName = File(url).nameWithoutExtension, contentType = contentType, contentSource = response.body.source(), - contentResolver = context.contentResolver, + contentResolver = context.contentResolver ) } else { saveContentDefault( fileName = File(url).name, contentSource = response.body.source(), - context = context, + context = context ) } onSuccess() @@ -79,7 +78,7 @@ object ImageSaver { displayName: String, contentType: String, contentSource: BufferedSource, - contentResolver: ContentResolver, + contentResolver: ContentResolver ) { val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) @@ -114,7 +113,7 @@ object ImageSaver { private fun saveContentDefault( fileName: String, contentSource: BufferedSource, - context: Context, + context: Context ) { val subdirectory = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), @@ -144,4 +143,4 @@ object ImageSaver { } private const val PICTURES_SUBDIRECTORY = "Amethyst" -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index ccb6b49cb1..e2fcf31b1b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -11,66 +11,66 @@ import java.io.IOException import java.util.* object ImageUploader { - fun uploadImage( - uri: Uri, - contentResolver: ContentResolver, - onSuccess: (String) -> Unit, - onError: (Throwable) -> Unit, -) { - val contentType = contentResolver.getType(uri) + fun uploadImage( + uri: Uri, + contentResolver: ContentResolver, + onSuccess: (String) -> Unit, + onError: (Throwable) -> Unit + ) { + val contentType = contentResolver.getType(uri) - val client = OkHttpClient.Builder().build() + val client = OkHttpClient.Builder().build() - val requestBody: RequestBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart( - "image", - "${UUID.randomUUID()}", - object : RequestBody() { - override fun contentType(): MediaType? = - contentType?.toMediaType() + val requestBody: RequestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "image", + "${UUID.randomUUID()}", + object : RequestBody() { + override fun contentType(): MediaType? = + contentType?.toMediaType() - override fun writeTo(sink: BufferedSink) { - val imageInputStream = contentResolver.openInputStream(uri) - checkNotNull(imageInputStream) { - "Can't open the image input stream" + override fun writeTo(sink: BufferedSink) { + val imageInputStream = contentResolver.openInputStream(uri) + checkNotNull(imageInputStream) { + "Can't open the image input stream" + } + + imageInputStream.source().use(sink::writeAll) + } + } + ) + .build() + + val request: Request = Request.Builder() + .url("https://api.imgur.com/3/image") + .header("Authorization", "Client-ID e6aea87296f3f96") + .post(requestBody) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + try { + check(response.isSuccessful) + response.body.use { body -> + val tree = jacksonObjectMapper().readTree(body.string()) + val url = tree?.get("data")?.get("link")?.asText() + checkNotNull(url) { + "There must be an uploaded image URL in the response" + } + + onSuccess(url) + } + } catch (e: Exception) { + e.printStackTrace() + onError(e) + } } - imageInputStream.source().use(sink::writeAll) - } - } - ) - .build() - - val request: Request = Request.Builder() - .url("https://api.imgur.com/3/image") - .header("Authorization", "Client-ID e6aea87296f3f96") - .post(requestBody) - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - try { - check(response.isSuccessful) - response.body.use { body -> - val tree = jacksonObjectMapper().readTree(body.string()) - val url = tree?.get("data")?.get("link")?.asText() - checkNotNull(url) { - "There must be an uploaded image URL in the response" + override fun onFailure(call: Call, e: IOException) { + e.printStackTrace() + onError(e) } - - onSuccess(url) - } - } catch (e: Exception) { - e.printStackTrace() - onError(e) - } - } - - override fun onFailure(call: Call, e: IOException) { - e.printStackTrace() - onError(e) - } - }) - } -} \ No newline at end of file + }) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt index 73d5148103..bc8bb5a0f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp @@ -42,8 +41,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu dismissOnClickOutside = false ) ) { - Surface( - ) { + Surface() { Column( modifier = Modifier.padding(10.dp) ) { @@ -124,7 +122,4 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu } } } - - - } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt index 95f7a481f4..bd68bc7cb6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel -class NewChannelViewModel: ViewModel() { +class NewChannelViewModel : ViewModel() { private var account: Account? = null private var originalChannel: Channel? = null @@ -15,7 +15,6 @@ class NewChannelViewModel: ViewModel() { val channelPicture = mutableStateOf(TextFieldValue()) val channelDescription = mutableStateOf(TextFieldValue()) - fun load(account: Account, channel: Channel?) { this.account = account if (channel != null) { @@ -34,13 +33,14 @@ class NewChannelViewModel: ViewModel() { channelDescription.value.text, channelPicture.value.text ) - } else + } else { account.sendChangeChannel( channelName.value.text, channelDescription.value.text, channelPicture.value.text, originalChannel!! ) + } } clear() @@ -51,4 +51,4 @@ class NewChannelViewModel: ViewModel() { channelPicture.value = TextFieldValue() channelDescription.value = TextFieldValue() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 7da8b44bb9..1df19f6c3a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -10,12 +10,13 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* +import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -class NewPostViewModel: ViewModel() { +class NewPostViewModel : ViewModel() { private var account: Account? = null private var originalNote: Note? = null @@ -76,10 +77,15 @@ class NewPostViewModel: ViewModel() { paragraph.split(' ').forEach { word: String -> val results = parseDirtyWordForKey(word) - if (results?.type == "npub") { - addUserToMentions(LocalCache.getOrCreateUser(results.keyHex)) - } else if (results?.type == "note") { - addNoteToReplyTos(LocalCache.getOrCreateNote(results.keyHex)) + if (results?.key?.type == Nip19.Type.USER) { + addUserToMentions(LocalCache.getOrCreateUser(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.NOTE) { + addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + addNoteToReplyTos(note) + } } } } @@ -88,14 +94,21 @@ class NewPostViewModel: ViewModel() { val newMessage = message.text.split('\n').map { paragraph: String -> paragraph.split(' ').map { word: String -> val results = parseDirtyWordForKey(word) - if (results?.type == "npub") { - val user = LocalCache.getOrCreateUser(results.keyHex) + if (results?.key?.type == Nip19.Type.USER) { + val user = LocalCache.getOrCreateUser(results.key.hex) "#[${tagIndex(user)}]${results.restOfWord}" - } else if (results?.type == "note") { - val note = LocalCache.getOrCreateNote(results.keyHex) + } else if (results?.key?.type == Nip19.Type.NOTE) { + val note = LocalCache.getOrCreateNote(results.key.hex) "#[${tagIndex(note)}]${results.restOfWord}" + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + "#[${tagIndex(note)}]${results.restOfWord}" + } else { + word + } } else { word } @@ -182,4 +195,4 @@ class NewPostViewModel: ViewModel() { userSuggestions = emptyList() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt index acc2ae430f..86502b8206 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -55,7 +55,6 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.service.relays.FeedType import java.lang.Math.round - @Composable fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") { val postViewModel: NewRelayListViewModel = viewModel() @@ -74,8 +73,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = dismissOnClickOutside = false ) ) { - Surface( - ) { + Surface() { Column( modifier = Modifier.padding(10.dp) ) { @@ -105,26 +103,27 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = contentPadding = PaddingValues( top = 10.dp, bottom = 10.dp - ), + ) ) { itemsIndexed(feedState, key = { _, item -> item.url }) { index, item -> - if (index == 0) + if (index == 0) { ServerConfigHeader() - ServerConfig(item, + } + ServerConfig( + item, onToggleDownload = { postViewModel.toggleDownload(it) }, - onToggleUpload = { postViewModel.toggleUpload(it) }, + onToggleUpload = { postViewModel.toggleUpload(it) }, onToggleFollows = { postViewModel.toggleFollows(it) }, - onTogglePrivateDMs = { postViewModel.toggleMessages(it) }, + onTogglePrivateDMs = { postViewModel.toggleMessages(it) }, onTogglePublicChats = { postViewModel.togglePublicChats(it) }, - onToggleGlobal = { postViewModel.toggleGlobal(it) }, - onToggleSearch = { postViewModel.toggleSearch(it) }, + onToggleGlobal = { postViewModel.toggleGlobal(it) }, + onToggleSearch = { postViewModel.toggleSearch(it) }, onDelete = { postViewModel.deleteRelay(it) } ) } } - } Spacer(modifier = Modifier.height(10.dp)) @@ -161,7 +160,7 @@ fun ServerConfigHeader() { maxLines = 1, fontSize = 14.sp, modifier = Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) Spacer(modifier = Modifier.size(10.dp)) @@ -171,7 +170,7 @@ fun ServerConfigHeader() { maxLines = 1, fontSize = 14.sp, modifier = Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) Spacer(modifier = Modifier.size(10.dp)) @@ -181,7 +180,7 @@ fun ServerConfigHeader() { maxLines = 1, fontSize = 14.sp, modifier = Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) Spacer(modifier = Modifier.size(5.dp)) @@ -191,7 +190,7 @@ fun ServerConfigHeader() { maxLines = 1, fontSize = 14.sp, modifier = Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) Spacer(modifier = Modifier.size(2.dp)) @@ -217,7 +216,8 @@ fun ServerConfig( onToggleGlobal: (RelaySetupInfo) -> Unit, onToggleSearch: (RelaySetupInfo) -> Unit, - onDelete: (RelaySetupInfo) -> Unit) { + onDelete: (RelaySetupInfo) -> Unit +) { Column(Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -262,9 +262,13 @@ fun ServerConfig( modifier = Modifier .padding(end = 5.dp) .size(15.dp), - tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) Color.Green else MaterialTheme.colors.onSurface.copy( - alpha = 0.32f - ) + tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) { + Color.Green + } else { + MaterialTheme.colors.onSurface.copy( + alpha = 0.32f + ) + } ) } IconButton( @@ -277,9 +281,13 @@ fun ServerConfig( modifier = Modifier .padding(horizontal = 5.dp) .size(15.dp), - tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) Color.Green else MaterialTheme.colors.onSurface.copy( - alpha = 0.32f - ) + tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) { + Color.Green + } else { + MaterialTheme.colors.onSurface.copy( + alpha = 0.32f + ) + } ) } IconButton( @@ -292,9 +300,13 @@ fun ServerConfig( modifier = Modifier .padding(horizontal = 5.dp) .size(15.dp), - tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) Color.Green else MaterialTheme.colors.onSurface.copy( - alpha = 0.32f - ) + tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) { + Color.Green + } else { + MaterialTheme.colors.onSurface.copy( + alpha = 0.32f + ) + } ) } IconButton( @@ -307,9 +319,13 @@ fun ServerConfig( modifier = Modifier .padding(horizontal = 5.dp) .size(15.dp), - tint = if (item.feedTypes.contains(FeedType.GLOBAL)) Color.Green else MaterialTheme.colors.onSurface.copy( - alpha = 0.32f - ) + tint = if (item.feedTypes.contains(FeedType.GLOBAL)) { + Color.Green + } else { + MaterialTheme.colors.onSurface.copy( + alpha = 0.32f + ) + } ) } @@ -323,9 +339,13 @@ fun ServerConfig( modifier = Modifier .padding(horizontal = 5.dp) .size(15.dp), - tint = if (item.feedTypes.contains(FeedType.SEARCH)) Color.Green else MaterialTheme.colors.onSurface.copy( - alpha = 0.32f - ) + tint = if (item.feedTypes.contains(FeedType.SEARCH)) { + Color.Green + } else { + MaterialTheme.colors.onSurface.copy( + alpha = 0.32f + ) + } ) } } @@ -343,9 +363,13 @@ fun ServerConfig( modifier = Modifier .padding(horizontal = 5.dp) .size(15.dp), - tint = if (item.read) Color.Green else MaterialTheme.colors.onSurface.copy( - alpha = 0.32f - ) + tint = if (item.read) { + Color.Green + } else { + MaterialTheme.colors.onSurface.copy( + alpha = 0.32f + ) + } ) } @@ -354,7 +378,7 @@ fun ServerConfig( maxLines = 1, fontSize = 14.sp, modifier = Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) IconButton( @@ -367,9 +391,13 @@ fun ServerConfig( modifier = Modifier .padding(horizontal = 5.dp) .size(15.dp), - tint = if (item.write) Color.Green else MaterialTheme.colors.onSurface.copy( - alpha = 0.32f - ) + tint = if (item.write) { + Color.Green + } else { + MaterialTheme.colors.onSurface.copy( + alpha = 0.32f + ) + } ) } @@ -378,7 +406,7 @@ fun ServerConfig( maxLines = 1, fontSize = 14.sp, modifier = Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) Icon( @@ -395,7 +423,7 @@ fun ServerConfig( maxLines = 1, fontSize = 14.sp, modifier = Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) Icon( @@ -410,7 +438,7 @@ fun ServerConfig( maxLines = 1, fontSize = 14.sp, modifier = Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } } @@ -433,7 +461,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni Row(verticalAlignment = Alignment.CenterVertically) { OutlinedTextField( label = { Text(text = stringResource(R.string.add_a_relay)) }, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f), value = url, onValueChange = { url = it }, placeholder = { @@ -487,14 +515,12 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni ) { Text(text = stringResource(id = R.string.add), color = Color.White) } - } } - fun countToHumanReadable(counter: Int) = when { - counter >= 1000000000 -> "${round(counter/1000000000f)}G" - counter >= 1000000 -> "${round(counter/1000000f)}M" - counter >= 1000 -> "${round(counter/1000f)}k" + counter >= 1000000000 -> "${round(counter / 1000000000f)}G" + counter >= 1000000 -> "${round(counter / 1000000f)}M" + counter >= 1000 -> "${round(counter / 1000f)}k" else -> "$counter" -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt index 5d0c86866a..0492ac077e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt @@ -7,13 +7,12 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.RelayPool import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -class NewRelayListViewModel: ViewModel() { +class NewRelayListViewModel : ViewModel() { private lateinit var account: Account private val _relays = MutableStateFlow>(emptyList()) @@ -40,11 +39,12 @@ class NewRelayListViewModel: ViewModel() { // TODO: Remove when search becomes more available. if (relayFile?.none { it.key == Constants.forcedRelayForSearch.url } == true) { relayFile = relayFile + Pair( - Constants.forcedRelayForSearch.url, ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write) + Constants.forcedRelayForSearch.url, + ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write) ) } - if (relayFile != null) + if (relayFile != null) { relayFile.map { val liveRelay = RelayPool.getRelay(it.key) val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet() @@ -56,7 +56,7 @@ class NewRelayListViewModel: ViewModel() { RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes) }.sortedBy { it.downloadCount }.reversed() - else + } else { account.localRelays.map { val liveRelay = RelayPool.getRelay(it.url) @@ -67,6 +67,7 @@ class NewRelayListViewModel: ViewModel() { RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes) }.sortedBy { it.downloadCount }.reversed() + } } } @@ -120,14 +121,14 @@ class NewRelayListViewModel: ViewModel() { fun toggleGlobal(relay: RelaySetupInfo) { val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL) _relays.update { - it.updated(relay, relay.copy( feedTypes = newTypes )) + it.updated(relay, relay.copy(feedTypes = newTypes)) } } fun toggleSearch(relay: RelaySetupInfo) { val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH) _relays.update { - it.updated(relay, relay.copy( feedTypes = newTypes )) + it.updated(relay, relay.copy(feedTypes = newTypes)) } } } @@ -136,4 +137,4 @@ fun Iterable.updated(old: T, new: T): List = map { if (it == old) new fun togglePresenceInSet(set: Set, item: T): Set { return if (set.contains(item)) set.minus(item) else set.plus(item) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt index b0f032bcc9..ac9bd2aea0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -42,8 +42,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) { dismissOnClickOutside = false ) ) { - Surface( - ) { + Surface() { Column( modifier = Modifier.padding(10.dp) ) { @@ -72,7 +71,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) { Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) { OutlinedTextField( label = { Text(text = stringResource(R.string.display_name)) }, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f), value = postViewModel.displayName.value, onValueChange = { postViewModel.displayName.value = it }, placeholder = { @@ -219,7 +218,6 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) { }, singleLine = true ) - } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 3708294138..a7857b9538 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -8,7 +8,7 @@ import com.vitorpamplona.amethyst.model.Account import java.io.ByteArrayInputStream import java.io.StringWriter -class NewUserMetadataViewModel: ViewModel() { +class NewUserMetadataViewModel : ViewModel() { private lateinit var account: Account val userName = mutableStateOf("") @@ -80,4 +80,4 @@ class NewUserMetadataViewModel: ViewModel() { lnAddress.value = "" lnURL.value = "" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt index 9e7bc7c3fc..e8dbca2cfc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt @@ -31,7 +31,6 @@ fun SaveToGallery(url: String) { val localContext = LocalContext.current val scope = rememberCoroutineScope() - fun saveImage() { ImageSaver.saveImage( context = localContext, @@ -83,4 +82,4 @@ fun SaveToGallery(url: String) { ) { Text(text = stringResource(id = R.string.save), color = Color.White) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt index a8f7d35105..a266e116ad 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt @@ -21,7 +21,7 @@ import com.vitorpamplona.amethyst.R @Composable fun UploadFromGallery( isUploading: Boolean, - onImageChosen: (Uri) -> Unit, + onImageChosen: (Uri) -> Unit ) { val cameraPermissionState = rememberPermissionState( @@ -38,8 +38,9 @@ fun UploadFromGallery( GallerySelect( onImageUri = { uri -> showGallerySelect = false - if (uri != null) + if (uri != null) { onImageChosen(uri) + } } ) } else { @@ -73,7 +74,7 @@ fun UploadFromGallery( Column { Button( onClick = { cameraPermissionState.launchPermissionRequest() }, - enabled = !isUploading, + enabled = !isUploading ) { if (!isUploading) { Text(stringResource(R.string.upload_image)) @@ -85,7 +86,6 @@ fun UploadFromGallery( } } - @Composable fun GallerySelect( onImageUri: (Uri?) -> Unit = { } @@ -105,4 +105,4 @@ fun GallerySelect( } LaunchGallery() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index d4b4f2e5dc..317162932d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -18,127 +18,128 @@ import kotlin.math.roundToInt data class RangesChanges(val original: TextRange, val modified: TextRange) class UrlUserTagTransformation(val color: Color) : VisualTransformation { - override fun filter(text: AnnotatedString): TransformedText { - return buildAnnotatedStringWithUrlHighlighting(text, color) - } + override fun filter(text: AnnotatedString): TransformedText { + return buildAnnotatedStringWithUrlHighlighting(text, color) + } } fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color): TransformedText { val substitutions = mutableListOf() val newText = buildAnnotatedString { - val builderBefore = StringBuilder() // important to correctly measure Tag start and end - val builderAfter = StringBuilder() // important to correctly measure Tag start and end - append( - text.split('\n').map { paragraph: String -> - paragraph.split(' ').map { word: String -> - try { - if (word.startsWith("@npub") && word.length >= 64) { - val keyB32 = word.substring(0, 64) - val restOfWord = word.substring(64) + val builderBefore = StringBuilder() // important to correctly measure Tag start and end + val builderAfter = StringBuilder() // important to correctly measure Tag start and end + append( + text.split('\n').map { paragraph: String -> + paragraph.split(' ').map { word: String -> + try { + if (word.startsWith("@npub") && word.length >= 64) { + val keyB32 = word.substring(0, 64) + val restOfWord = word.substring(64) - val startIndex = builderBefore.toString().length + val startIndex = builderBefore.toString().length - builderBefore.append("$keyB32$restOfWord ") // accounts for the \n at the end of each paragraph + builderBefore.append("$keyB32$restOfWord ") // accounts for the \n at the end of each paragraph - val endIndex = startIndex + keyB32.length + val endIndex = startIndex + keyB32.length - val key = decodePublicKey(keyB32.removePrefix("@")) - val user = LocalCache.getOrCreateUser(key.toHexKey()) + val key = decodePublicKey(keyB32.removePrefix("@")) + val user = LocalCache.getOrCreateUser(key.toHexKey()) - val newWord = "@${user.toBestDisplayName()}" - val startNew = builderAfter.toString().length + val newWord = "@${user.toBestDisplayName()}" + val startNew = builderAfter.toString().length - builderAfter.append("$newWord$restOfWord ") // accounts for the \n at the end of each paragraph + builderAfter.append("$newWord$restOfWord ") // accounts for the \n at the end of each paragraph - substitutions.add( - RangesChanges( - TextRange(startIndex, endIndex), - TextRange(startNew, startNew + newWord.length) - ) - ) - newWord + restOfWord - } else { - builderBefore.append(word + " ") - builderAfter.append(word + " ") - word - } - } catch (e: Exception) { - // if it can't parse the key, don't try to change. - builderBefore.append(word + " ") - builderAfter.append(word + " ") - word - } - }.joinToString(" ") - }.joinToString("\n") - ) - - val newText = toAnnotatedString() - - newText.split("\\s+".toRegex()).filter { word -> - Patterns.WEB_URL.matcher(word).matches() - }.forEach { - val startIndex = text.indexOf(it) - val endIndex = startIndex + it.length - addStyle( - style = SpanStyle( - color = color, - textDecoration = TextDecoration.None - ), - start = startIndex, end = endIndex + substitutions.add( + RangesChanges( + TextRange(startIndex, endIndex), + TextRange(startNew, startNew + newWord.length) + ) + ) + newWord + restOfWord + } else { + builderBefore.append(word + " ") + builderAfter.append(word + " ") + word + } + } catch (e: Exception) { + // if it can't parse the key, don't try to change. + builderBefore.append(word + " ") + builderAfter.append(word + " ") + word + } + }.joinToString(" ") + }.joinToString("\n") ) - } - substitutions.forEach { - addStyle( - style = SpanStyle( - color = color, - textDecoration = TextDecoration.None - ), - start = it.modified.start, end = it.modified.end - ) - } + val newText = toAnnotatedString() + + newText.split("\\s+".toRegex()).filter { word -> + Patterns.WEB_URL.matcher(word).matches() + }.forEach { + val startIndex = text.indexOf(it) + val endIndex = startIndex + it.length + addStyle( + style = SpanStyle( + color = color, + textDecoration = TextDecoration.None + ), + start = startIndex, + end = endIndex + ) + } + + substitutions.forEach { + addStyle( + style = SpanStyle( + color = color, + textDecoration = TextDecoration.None + ), + start = it.modified.start, + end = it.modified.end + ) + } } val numberOffsetTranslator = object : OffsetMapping { override fun originalToTransformed(offset: Int): Int { - val inInsideRange = substitutions.filter { offset > it.original.start && offset < it.original.end }.firstOrNull() + val inInsideRange = substitutions.filter { offset > it.original.start && offset < it.original.end }.firstOrNull() - if (inInsideRange != null) { - val percentInRange = (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) - return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).roundToInt() - } + if (inInsideRange != null) { + val percentInRange = (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) + return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).roundToInt() + } - val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } + val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } - if (lastRangeThrough != null) { - return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end) - } else { - return offset - } + if (lastRangeThrough != null) { + return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end) + } else { + return offset + } } override fun transformedToOriginal(offset: Int): Int { - val inInsideRange = substitutions.filter { offset > it.modified.start && offset < it.modified.end }.firstOrNull() + val inInsideRange = substitutions.filter { offset > it.modified.start && offset < it.modified.end }.firstOrNull() - if (inInsideRange != null) { - val percentInRange = (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) - return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).roundToInt() - } + if (inInsideRange != null) { + val percentInRange = (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) + return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).roundToInt() + } - val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } + val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } - if (lastRangeThrough != null) { - return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end) - } else { - return offset - } + if (lastRangeThrough != null) { + return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end) + } else { + return offset + } } } return TransformedText( - newText, - numberOffsetTranslator + newText, + numberOffsetTranslator ) - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt index 88a506244c..be05eeca1a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt @@ -28,15 +28,16 @@ fun NewChannelButton(account: Account) { mutableStateOf(false) } - if (wantsToPost) + if (wantsToPost) { NewChannelView({ wantsToPost = false }, account = account) + } OutlinedButton( onClick = { wantsToPost = true }, modifier = Modifier.size(55.dp), shape = CircleShape, colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), - contentPadding = PaddingValues(0.dp), + contentPadding = PaddingValues(0.dp) ) { Icon( imageVector = Icons.Outlined.Add, @@ -45,4 +46,4 @@ fun NewChannelButton(account: Account) { tint = Color.White ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt index 78e251a159..b16534b28f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt @@ -26,15 +26,16 @@ fun NewNoteButton(account: Account) { mutableStateOf(false) } - if (wantsToPost) + if (wantsToPost) { NewPostView({ wantsToPost = false }, account = account) + } OutlinedButton( onClick = { wantsToPost = true }, modifier = Modifier.size(55.dp), shape = CircleShape, colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), - contentPadding = PaddingValues(0.dp), + contentPadding = PaddingValues(0.dp) ) { Icon( painter = painterResource(R.drawable.ic_compose), @@ -43,4 +44,4 @@ fun NewNoteButton(account: Account) { tint = Color.White ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AsyncImageProxy.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AsyncImageProxy.kt index 8afdec0176..8e4dbcd33b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AsyncImageProxy.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AsyncImageProxy.kt @@ -9,76 +9,73 @@ import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import coil.compose.AsyncImage import coil.compose.AsyncImagePainter -import coil.compose.LocalImageLoader import java.util.Base64 data class ResizeImage(val url: String?, val size: Dp) { - fun proxyUrl(): String? { - if (url == null) return null + fun proxyUrl(): String? { + if (url == null) return null - // Fixes Image size to reduce pings to servers for each size used in the app - val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() } - val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray()) + // Fixes Image size to reduce pings to servers for each size used in the app + val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() } + val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray()) - return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64" - } + return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64" + } } - @Composable fun AsyncImageProxy( - model: ResizeImage, - contentDescription: String?, - modifier: Modifier = Modifier, - placeholder: Painter? = null, - error: Painter? = null, - fallback: Painter? = error, - onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null, - onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null, - onError: ((AsyncImagePainter.State.Error) -> Unit)? = null, - alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, - alpha: Float = DefaultAlpha, - colorFilter: ColorFilter? = null, - filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, + model: ResizeImage, + contentDescription: String?, + modifier: Modifier = Modifier, + placeholder: Painter? = null, + error: Painter? = null, + fallback: Painter? = error, + onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null, + onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null, + onError: ((AsyncImagePainter.State.Error) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - if (model.url == null) { - AsyncImage( - model = model.url, - contentDescription = contentDescription, - modifier = modifier, - placeholder = placeholder, - error = error, - fallback = fallback, - onLoading = onLoading, - onSuccess = onSuccess, - onError = onError, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) - } else { - AsyncImage( - model = model.proxyUrl(), - contentDescription = contentDescription, - modifier = modifier, - placeholder = placeholder, - error = error, - fallback = fallback, - onLoading = onLoading, - onSuccess = onSuccess, - onError = onError, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) - } -} \ No newline at end of file + if (model.url == null) { + AsyncImage( + model = model.url, + contentDescription = contentDescription, + modifier = modifier, + placeholder = placeholder, + error = error, + fallback = fallback, + onLoading = onLoading, + onSuccess = onSuccess, + onError = onError, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } else { + AsyncImage( + model = model.proxyUrl(), + contentDescription = contentDescription, + modifier = modifier, + placeholder = placeholder, + error = error, + fallback = fallback, + onLoading = onLoading, + onSuccess = onSuccess, + onError = onError, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt index 54a4025af8..23cd96fee3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt @@ -12,26 +12,27 @@ import androidx.compose.ui.text.AnnotatedString @Composable fun ClickableEmail(email: String) { - val context = LocalContext.current + val context = LocalContext.current - ClickableText( - text = AnnotatedString("$email "), - onClick = { runCatching { context.sendMail(email) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), - ) + ClickableText( + text = AnnotatedString("$email "), + onClick = { runCatching { context.sendMail(email) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) } fun Context.sendMail(to: String, subject: String? = null) { - try { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "vnd.android.cursor.item/email" // or "message/rfc822" - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - if (subject != null) - intent.putExtra(Intent.EXTRA_SUBJECT, subject) - startActivity(intent) - } catch (e: ActivityNotFoundException) { - // TODO: Handle case where no email app is available - } catch (t: Throwable) { - // TODO: Handle potential other type of exceptions - } -} \ No newline at end of file + try { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "vnd.android.cursor.item/email" // or "message/rfc822" + intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) + if (subject != null) { + intent.putExtra(Intent.EXTRA_SUBJECT, subject) + } + startActivity(intent) + } catch (e: ActivityNotFoundException) { + // TODO: Handle case where no email app is available + } catch (t: Throwable) { + // TODO: Handle potential other type of exceptions + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt index fbf0b079fa..42ddcd1cd9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt @@ -11,12 +11,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex @Composable fun ClickableNoteTag( - baesNote: Note, - navController: NavController + baesNote: Note, + navController: NavController ) { - ClickableText( - text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "), - onClick = { navController.navigate("Note/${baesNote.idHex}") }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) - ) -} \ No newline at end of file + ClickableText( + text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "), + onClick = { navController.navigate("Note/${baesNote.idHex}") }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt index 04fc3d4a90..59e85d637d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt @@ -12,20 +12,20 @@ import androidx.compose.ui.text.AnnotatedString @Composable fun ClickablePhone(phone: String) { - val context = LocalContext.current + val context = LocalContext.current - ClickableText( - text = AnnotatedString("$phone "), - onClick = { runCatching { context.dial(phone) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), - ) + ClickableText( + text = AnnotatedString("$phone "), + onClick = { runCatching { context.dial(phone) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) } fun Context.dial(phone: String) { - try { - val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) - startActivity(intent) - } catch (t: Throwable) { - // TODO: Handle potential exceptions - } -} \ No newline at end of file + try { + val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) + startActivity(intent) + } catch (t: Throwable) { + // TODO: Handle potential exceptions + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 6ef3abb294..af4cc78c77 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -3,57 +3,79 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.text.ClickableText import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.text.AnnotatedString import androidx.navigation.NavController import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.Nip19 import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent +import com.vitorpamplona.amethyst.service.nip19.Nip19 @Composable fun ClickableRoute( - nip19: Nip19.Return, - navController: NavController + nip19: Nip19.Return, + navController: NavController ) { - if (nip19.type == Nip19.Type.USER) { - val userBase = LocalCache.getOrCreateUser(nip19.hex) + if (nip19.type == Nip19.Type.USER) { + val userBase = LocalCache.getOrCreateUser(nip19.hex) - val userState by userBase.live().metadata.observeAsState() - val user = userState?.user ?: return + val userState by userBase.live().metadata.observeAsState() + val user = userState?.user ?: return - val route = "User/${nip19.hex}" - val text = user.toBestDisplayName() + val route = "User/${nip19.hex}" + val text = user.toBestDisplayName() - ClickableText( - text = AnnotatedString("@${text} "), - onClick = { navController.navigate(route) }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) - ) - } else { - val noteBase = LocalCache.getOrCreateNote(nip19.hex) - val noteState by noteBase.live().metadata.observeAsState() - val note = noteState?.note ?: return + ClickableText( + text = AnnotatedString("@$text "), + onClick = { navController.navigate(route) }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) + } else if (nip19.type == Nip19.Type.ADDRESS) { + val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex) - if (note.event is ChannelCreateEvent) { - ClickableText( - text = AnnotatedString("@${note.idDisplayNote()} "), - onClick = { navController.navigate("Channel/${nip19.hex}") }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) - ) - } else if (note.channel() != null) { - ClickableText( - text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "), - onClick = { navController.navigate("Channel/${note.channel()?.idHex}") }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) - ) + if (noteBase == null) { + Text( + "@${nip19.hex} " + ) + } else { + val noteState by noteBase.live().metadata.observeAsState() + val note = noteState?.note ?: return + + ClickableText( + text = AnnotatedString("@${note.idDisplayNote()} "), + onClick = { navController.navigate("Note/${nip19.hex}") }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) + } + } else if (nip19.type == Nip19.Type.NOTE) { + val noteBase = LocalCache.getOrCreateNote(nip19.hex) + val noteState by noteBase.live().metadata.observeAsState() + val note = noteState?.note ?: return + + if (note.event is ChannelCreateEvent) { + ClickableText( + text = AnnotatedString("@${note.idDisplayNote()} "), + onClick = { navController.navigate("Channel/${nip19.hex}") }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) + } else if (note.channel() != null) { + ClickableText( + text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "), + onClick = { navController.navigate("Channel/${note.channel()?.idHex}") }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) + } else { + ClickableText( + text = AnnotatedString("@${note.idDisplayNote()} "), + onClick = { navController.navigate("Note/${nip19.hex}") }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) + } } else { - ClickableText( - text = AnnotatedString("@${note.idDisplayNote()} "), - onClick = { navController.navigate("Note/${nip19.hex}") }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) - ) + Text( + "@${nip19.hex} " + ) } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index 7db7a23101..731f8bfa16 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -9,11 +9,11 @@ import androidx.compose.ui.text.AnnotatedString @Composable fun ClickableUrl(urlText: String, url: String) { - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - ClickableText( - text = AnnotatedString("$urlText "), - onClick = { runCatching { uri.openUri(url) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), - ) -} \ No newline at end of file + ClickableText( + text = AnnotatedString("$urlText "), + onClick = { runCatching { uri.openUri(url) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt index 8cd690d408..2f36de982e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt @@ -12,13 +12,13 @@ import com.vitorpamplona.amethyst.model.User @Composable fun ClickableUserTag( - user: User, - navController: NavController + user: User, + navController: NavController ) { - val innerUserState by user.live().metadata.observeAsState() - ClickableText( - text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "), - onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) - ) -} \ No newline at end of file + val innerUserState by user.live().metadata.observeAsState() + ClickableText( + text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "), + onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 70bdc114ab..5e31fa2934 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -13,7 +13,6 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -23,68 +22,66 @@ 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.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.R -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.LayoutDirection +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable fun ExpandableRichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier = Modifier, - tags: List>?, - backgroundColor: Color, - accountViewModel: AccountViewModel, - navController: NavController + content: String, + canPreview: Boolean, + modifier: Modifier = Modifier, + tags: List>?, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController ) { - var showFullText by remember { mutableStateOf(false) } + var showFullText by remember { mutableStateOf(false) } - val text = if (showFullText) content else content.take(350) + val text = if (showFullText) content else content.take(350) - Box(contentAlignment = Alignment.BottomCenter) { - //CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - RichTextViewer( - text, - canPreview, - modifier, - tags, - backgroundColor, - accountViewModel, - navController - ) - //} + Box(contentAlignment = Alignment.BottomCenter) { + // CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + RichTextViewer( + text, + canPreview, + modifier, + tags, + backgroundColor, + accountViewModel, + navController + ) + // } - if (content.length > 350 && !showFullText) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.verticalGradient( - colors = listOf( - backgroundColor.copy(alpha = 0f), - backgroundColor - ) - ) - ) - ) { - Button( - modifier = Modifier.padding(top = 10.dp), - onClick = { showFullText = !showFullText }, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background) - ), - contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) - ) { - Text(text = stringResource(R.string.show_more), color = Color.White) + if (content.length > 350 && !showFullText) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + backgroundColor.copy(alpha = 0f), + backgroundColor + ) + ) + ) + ) { + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = { showFullText = !showFullText }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background) + ), + contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(R.string.show_more), color = Color.White) + } + } } - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index fe765a63a4..f945164000 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -33,82 +33,80 @@ import java.text.NumberFormat @Composable fun InvoicePreview(lnInvoice: String) { - val amount = try { - LnInvoiceUtil.getAmountInSats(lnInvoice) - } catch (e: Exception) { - e.printStackTrace() - null - } + val amount = try { + LnInvoiceUtil.getAmountInSats(lnInvoice) + } catch (e: Exception) { + e.printStackTrace() + null + } - val context = LocalContext.current - - Column(modifier = Modifier - .fillMaxWidth() - .padding(start = 30.dp, end = 30.dp) - .clip(shape = RoundedCornerShape(10.dp)) - .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) - ) { + val context = LocalContext.current Column( - modifier = Modifier - .fillMaxWidth() - .padding(30.dp) - ) { - - Row( - verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Icon( - painter = painterResource(R.drawable.lightning), - null, - modifier = Modifier.size(20.dp), - tint = Color.Unspecified - ) - - Text( - text = stringResource(R.string.lightning_invoice), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp) - ) - } - - Divider() - - amount?.let { - Text( - text = "${ - NumberFormat.getInstance().format(amount) - } ${stringResource(id = R.string.sats)}", - fontSize = 25.sp, - fontWeight = FontWeight.W500, - modifier = Modifier .fillMaxWidth() - .padding(vertical = 10.dp), - ) - } + .padding(start = 30.dp, end = 30.dp) + .clip(shape = RoundedCornerShape(10.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(30.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + Text( + text = stringResource(R.string.lightning_invoice), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp) + ) + } - Button( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp), - onClick = { - runCatching { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice")) - startActivity(context, intent, null) - } - }, - shape = RoundedCornerShape(15.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp) - } + Divider() + + amount?.let { + Text( + text = "${ + NumberFormat.getInstance().format(amount) + } ${stringResource(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp) + ) + } + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + onClick = { + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice")) + startActivity(context, intent, null) + } + }, + shape = RoundedCornerShape(15.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp) + } + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt index e0e430c0a3..63d96bb35b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -38,123 +38,127 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.startActivity import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import kotlinx.coroutines.launch @Composable -fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - - Column(modifier = Modifier - .fillMaxWidth() - .padding(start = 30.dp, end = 30.dp) - .clip(shape = RoundedCornerShape(10.dp)) - .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) - ) { +fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() Column( - modifier = Modifier - .fillMaxWidth() - .padding(30.dp) - ) { - - Row( - verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Icon( - painter = painterResource(R.drawable.lightning), - null, - modifier = Modifier.size(20.dp), - tint = Color.Unspecified - ) + .fillMaxWidth() + .padding(start = 30.dp, end = 30.dp) + .clip(shape = RoundedCornerShape(10.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(30.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) - Text( - text = stringResource(R.string.lightning_tips), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp) - ) - } - - Divider() - - var message by remember { mutableStateOf("") } - var amount by remember { mutableStateOf(1000L) } - - OutlinedTextField( - label = { Text(text = stringResource(R.string.note_to_receiver)) }, - modifier = Modifier.fillMaxWidth(), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.thank_you_so_much), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - singleLine = true - ) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.amount_in_sats)) }, - modifier = Modifier.fillMaxWidth(), - value = amount.toString(), - onValueChange = { - runCatching { - if (it.isEmpty()) - amount = 0 - else - amount = it.toLong() - } - }, - placeholder = { - Text( - text = "1000", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number - ), - singleLine = true - ) - - Button( - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - onClick = { - val zapRequest = account.createZapRequestFor(toUserPubKeyHex) - - LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message, zapRequest?.toJson(), - onSuccess = { - runCatching { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) - startActivity(context, intent, null) - } - onClose() - }, - onError = { - scope.launch { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - onClose() - } + Text( + text = stringResource(R.string.lightning_tips), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp) + ) } - ) - }, - shape = RoundedCornerShape(15.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp) - } + + Divider() + + var message by remember { mutableStateOf("") } + var amount by remember { mutableStateOf(1000L) } + + OutlinedTextField( + label = { Text(text = stringResource(R.string.note_to_receiver)) }, + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.thank_you_so_much), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ), + singleLine = true + ) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.amount_in_sats)) }, + modifier = Modifier.fillMaxWidth(), + value = amount.toString(), + onValueChange = { + runCatching { + if (it.isEmpty()) { + amount = 0 + } else { + amount = it.toLong() + } + } + }, + placeholder = { + Text( + text = "1000", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number + ), + singleLine = true + ) + + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { + val zapRequest = account.createZapRequestFor(toUserPubKeyHex) + + LightningAddressResolver().lnAddressInvoice( + lud16, + amount * 1000, + message, + zapRequest?.toJson(), + onSuccess = { + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) + startActivity(context, intent, null) + } + onClose() + }, + onError = { + scope.launch { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + onClose() + } + } + ) + }, + shape = RoundedCornerShape(15.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp) + } + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 79706fe341..59e223fe92 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -8,28 +8,18 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.LocalContentColor import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp @@ -41,9 +31,9 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material.MaterialRichText import com.halilibo.richtext.ui.resolveDefaults -import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.Nip19 +import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil +import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import java.net.MalformedURLException @@ -61,238 +51,228 @@ val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)") val urlPattern: Pattern = Patterns.WEB_URL fun isValidURL(url: String?): Boolean { - return try { - URL(url).toURI() - true - } catch (e: MalformedURLException) { - false - } catch (e: URISyntaxException) { - false - } + return try { + URL(url).toURI() + true + } catch (e: MalformedURLException) { + false + } catch (e: URISyntaxException) { + false + } } @Composable fun RichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier = Modifier, - tags: List>?, - backgroundColor: Color, - accountViewModel: AccountViewModel, - navController: NavController, + content: String, + canPreview: Boolean, + modifier: Modifier = Modifier, + tags: List>?, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController ) { - - val myMarkDownStyle = RichTextStyle().resolveDefaults().copy( - codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy( - textStyle = TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = 14.sp - ), - modifier = Modifier - .padding(0.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) + val myMarkDownStyle = RichTextStyle().resolveDefaults().copy( + codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy( + textStyle = TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp + ), + modifier = Modifier + .padding(0.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ) + .background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor)) + ), + stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy( + linkStyle = SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colors.primary + ), + codeStyle = SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor) + ) ) - .background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor)) - ), - stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy( - linkStyle = SpanStyle( - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colors.primary - ), - codeStyle = SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor) - ) ) - ) - Column(modifier = modifier.animateContentSize()) { - - if ( content.startsWith("# ") - || content.contains("##") - || content.contains("**") - || content.contains("__") - || content.contains("```") - ) { - - MaterialRichText( - style = myMarkDownStyle, - ) { - Markdown( - content = content, - markdownParseOptions = MarkdownParseOptions.Default, - ) - } - } else { - // FlowRow doesn't work well with paragraphs. So we need to split them - content.split('\n').forEach { paragraph -> - FlowRow() { - val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' '); - s.forEach { word: String -> - if (canPreview) { - // Explicit URL - val lnInvoice = LnInvoiceUtil.findInvoice(word) - if (lnInvoice != null) { - InvoicePreview(lnInvoice) - } else if (isValidURL(word)) { - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtension.matcher(removedParamsFromUrl).matches()) { - ZoomableImageView(word) - } else if (videoExtension.matcher(removedParamsFromUrl).matches()) { - VideoView(word) - } else { - UrlPreview(word, word) - } - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - ClickableEmail(word) - } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { - ClickablePhone(word) - } else if (noProtocolUrlValidator.matcher(word).matches()) { - UrlPreview("https://$word", word) - } else if (tagIndex.matcher(word).matches() && tags != null) { - TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) - } else if (isBechLink(word)) { - BechLink(word, navController) - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + Column(modifier = modifier.animateContentSize()) { + if (content.startsWith("# ") || + content.contains("##") || + content.contains("**") || + content.contains("__") || + content.contains("```") + ) { + MaterialRichText( + style = myMarkDownStyle + ) { + Markdown( + content = content, + markdownParseOptions = MarkdownParseOptions.Default ) - } - } else { - if (isValidURL(word)) { - ClickableUrl("$word ", word) - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - ClickableEmail(word) - } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { - ClickablePhone(word) - } else if (noProtocolUrlValidator.matcher(word).matches()) { - ClickableUrl(word, "https://$word") - } else if (tagIndex.matcher(word).matches() && tags != null) { - TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) - } else if (isBechLink(word)) { - BechLink(word, navController) - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - } } - } + } else { + // FlowRow doesn't work well with paragraphs. So we need to split them + content.split('\n').forEach { paragraph -> + FlowRow() { + val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ') + s.forEach { word: String -> + if (canPreview) { + // Explicit URL + val lnInvoice = LnInvoiceUtil.findInvoice(word) + if (lnInvoice != null) { + InvoicePreview(lnInvoice) + } else if (isValidURL(word)) { + val removedParamsFromUrl = word.split("?")[0].lowercase() + if (imageExtension.matcher(removedParamsFromUrl).matches()) { + ZoomableImageView(word) + } else if (videoExtension.matcher(removedParamsFromUrl).matches()) { + VideoView(word) + } else { + UrlPreview(word, word) + } + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + ClickableEmail(word) + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + ClickablePhone(word) + } else if (noProtocolUrlValidator.matcher(word).matches()) { + UrlPreview("https://$word", word) + } else if (tagIndex.matcher(word).matches() && tags != null) { + TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) + } else if (isBechLink(word)) { + BechLink(word, navController) + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } + } else { + if (isValidURL(word)) { + ClickableUrl("$word ", word) + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + ClickableEmail(word) + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + ClickablePhone(word) + } else if (noProtocolUrlValidator.matcher(word).matches()) { + ClickableUrl(word, "https://$word") + } else if (tagIndex.matcher(word).matches() && tags != null) { + TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) + } else if (isBechLink(word)) { + BechLink(word, navController) + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } + } + } + } + } } - } } - } } private fun isArabic(text: String): Boolean { - return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } + return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } } - fun isBechLink(word: String): Boolean { - return word.startsWith("nostr:", true) - || word.startsWith("npub1", true) - || word.startsWith("note1", true) - || word.startsWith("nprofile1", true) - || word.startsWith("nevent1", true) - || word.startsWith("@npub1", true) - || word.startsWith("@note1", true) - || word.startsWith("@nprofile1", true) - || word.startsWith("@nevent1", true) + val cleaned = word.removePrefix("@").removePrefix("nostr:").removePrefix("@") + + return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it, true) } } @Composable fun BechLink(word: String, navController: NavController) { - val uri = if (word.startsWith("nostr", true)) { - word - } else if (word.startsWith("@")) { - word.replaceFirst("@", "nostr:") - } else { - "nostr:${word}" - } + val uri = if (word.startsWith("nostr", true)) { + word + } else if (word.startsWith("@")) { + word.replaceFirst("@", "nostr:") + } else { + "nostr:$word" + } - val nip19Route = try { - Nip19().uriToRoute(uri) - } catch (e: Exception) { - null - } + val nip19Route = try { + Nip19.uriToRoute(uri) + } catch (e: Exception) { + null + } - if (nip19Route == null) { - Text(text = "$word ") - } else { - ClickableRoute(nip19Route, navController) - } + if (nip19Route == null) { + Text(text = "$word ") + } else { + ClickableRoute(nip19Route, navController) + } } - @Composable fun TagLink(word: String, tags: List>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) { - val matcher = tagIndex.matcher(word) + val matcher = tagIndex.matcher(word) - val index = try { - matcher.find() - matcher.group(1).toInt() - } catch (e: Exception) { - println("Couldn't link tag ${word}") - null - } + val index = try { + matcher.find() + matcher.group(1).toInt() + } catch (e: Exception) { + println("Couldn't link tag $word") + null + } - if (index == null) { - return Text(text = "$word ") - } + if (index == null) { + return Text(text = "$word ") + } - if (index >= 0 && index < tags.size) { - if (tags[index][0] == "p") { - val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1]) - if (baseUser != null) { - val userState = baseUser.live().metadata.observeAsState() - val user = userState.value?.user - if (user != null) { - ClickableUserTag(user, navController) + if (index >= 0 && index < tags.size) { + if (tags[index][0] == "p") { + val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1]) + if (baseUser != null) { + val userState = baseUser.live().metadata.observeAsState() + val user = userState.value?.user + if (user != null) { + ClickableUserTag(user, navController) + } else { + Text(text = "$word ") + } + } else { + // if here the tag is not a valid Nostr Hex + Text(text = "$word ") + } + } else if (tags[index][0] == "e") { + val note = LocalCache.checkGetOrCreateNote(tags[index][1]) + if (note != null) { + if (canPreview) { + NoteCompose( + baseNote = note, + accountViewModel = accountViewModel, + modifier = Modifier + .padding(0.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ), + parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) + .compositeOver(backgroundColor), + isQuotedNote = true, + navController = navController + ) + } else { + ClickableNoteTag(note, navController) + } + } else { + // if here the tag is not a valid Nostr Hex + Text(text = "$word ") + } } else { - Text(text = "$word ") + Text(text = "$word ") } - } else { - // if here the tag is not a valid Nostr Hex - Text(text = "$word ") - } - } else if (tags[index][0] == "e") { - val note = LocalCache.checkGetOrCreateNote(tags[index][1]) - if (note != null) { - if (canPreview) { - NoteCompose( - baseNote = note, - accountViewModel = accountViewModel, - modifier = Modifier - .padding(0.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) - ), - parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) - .compositeOver(backgroundColor), - isQuotedNote = true, - navController = navController - ) - } else { - ClickableNoteTag(note, navController) - } - } else { - // if here the tag is not a valid Nostr Hex - Text(text = "$word ") - } - } else - Text(text = "$word ") - } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt new file mode 100644 index 0000000000..bd9d488b96 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt @@ -0,0 +1,57 @@ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.R + +@Composable +fun SelectTextDialog(text: String, onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss + ) { + Card { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + IconButton( + onClick = onDismiss, + modifier = Modifier.background(MaterialTheme.colors.background) + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + Text(text = stringResource(R.string.select_text_dialog_top)) + } + Divider() + Row(modifier = Modifier.padding(16.dp)) { + SelectionContainer { + Text(text) + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt index 367c6f2fdd..2c11013ca8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt @@ -39,209 +39,216 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.lang.ResultOrError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.util.Locale @Composable fun TranslateableRichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier = Modifier, - tags: List>?, - backgroundColor: Color, - accountViewModel: AccountViewModel, - navController: NavController + content: String, + canPreview: Boolean, + modifier: Modifier = Modifier, + tags: List>?, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController ) { - val translatedTextState = remember { - mutableStateOf(ResultOrError(content, null, null, null)) - } - - var showOriginal by remember { mutableStateOf(false) } - var langSettingsPopupExpanded by remember { mutableStateOf(false) } - - val context = LocalContext.current - - val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() - val account = accountState?.account ?: return - - LaunchedEffect(accountState) { - withContext(Dispatchers.IO) { - LanguageTranslatorService.autoTranslate( - content, - account.dontTranslateFrom, - account.translateTo - ).addOnCompleteListener { task -> - if (task.isSuccessful && content != task.result.result) { - if (task.result.sourceLang != null && task.result.targetLang != null) { - val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!) - showOriginal = preference == task.result.sourceLang - } - translatedTextState.value = task.result - } else { - translatedTextState.value = ResultOrError(content, null, null, null) - } - } + val translatedTextState = remember { + mutableStateOf(ResultOrError(content, null, null, null)) } - } - val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content + var showOriginal by remember { mutableStateOf(false) } + var langSettingsPopupExpanded by remember { mutableStateOf(false) } - Column(modifier = Modifier.padding(top = 5.dp)) { - ExpandableRichTextViewer( - toBeViewed, - canPreview, - modifier, - tags, - backgroundColor, - accountViewModel, - navController, - ) + val context = LocalContext.current - val target = translatedTextState.value.targetLang - val source = translatedTextState.value.sourceLang + val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() + val account = accountState?.account ?: return - if (source != null && target != null) { - if (source != target) { - Row(modifier = Modifier - .fillMaxWidth() - .padding(top = 5.dp)) { - val clickableTextStyle = - SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f)) - - val annotatedTranslationString = buildAnnotatedString { - withStyle(clickableTextStyle) { - pushStringAnnotation("langSettings", true.toString()) - append(stringResource(R.string.auto)) - } - - append("-${stringResource(R.string.translated_from)} ") - - withStyle(clickableTextStyle) { - pushStringAnnotation("showOriginal", true.toString()) - append(Locale(source).displayName) - } - - append(" ${stringResource(R.string.to)} ") - - withStyle(clickableTextStyle) { - pushStringAnnotation("showOriginal", false.toString()) - append(Locale(target).displayName) - } - } - - ClickableText( - text = annotatedTranslationString, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)), - overflow = TextOverflow.Visible, - maxLines = 3 - ) { spanOffset -> - annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset) - .firstOrNull() - ?.also { span -> - if (span.tag == "showOriginal") - showOriginal = span.item.toBoolean() - else - langSettingsPopupExpanded = !langSettingsPopupExpanded - } - } - - DropdownMenu( - expanded = langSettingsPopupExpanded, - onDismissRequest = { langSettingsPopupExpanded = false } - ) { - DropdownMenuItem(onClick = { - accountViewModel.dontTranslateFrom(source, context) - langSettingsPopupExpanded = false - }) { - if (source in account.dontTranslateFrom) - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - else - Spacer(modifier = Modifier.size(24.dp)) - - Spacer(modifier = Modifier.size(10.dp)) - - Text(stringResource(R.string.never_translate_from) + "${Locale(source).displayName}") - } - Divider() - DropdownMenuItem(onClick = { - accountViewModel.prefer(source, target, source) - langSettingsPopupExpanded = false - }) { - if (account.preferenceBetween(source, target) == source) - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - else - Spacer(modifier = Modifier.size(24.dp)) - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - "${stringResource(R.string.show_in)} ${Locale(source).displayName} ${ - stringResource( - R.string.first - ) - }" - ) - } - DropdownMenuItem(onClick = { - accountViewModel.prefer(source, target, target) - langSettingsPopupExpanded = false - }) { - if (account.preferenceBetween(source, target) == target) - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - else - Spacer(modifier = Modifier.size(24.dp)) - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - "${stringResource(R.string.show_in)} ${Locale(target).displayName} ${ - stringResource( - R.string.first - ) - }" - ) - } - Divider() - - val languageList = - ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { lang -> - DropdownMenuItem(onClick = { - accountViewModel.translateTo(lang, context) - langSettingsPopupExpanded = false - }) { - if (lang.language in account.translateTo) - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - else - Spacer(modifier = Modifier.size(24.dp)) - - Spacer(modifier = Modifier.size(10.dp)) - - Text("${stringResource(R.string.always_translate_to)}${lang.displayName}") + LaunchedEffect(accountState) { + withContext(Dispatchers.IO) { + LanguageTranslatorService.autoTranslate( + content, + account.dontTranslateFrom, + account.translateTo + ).addOnCompleteListener { task -> + if (task.isSuccessful && content != task.result.result) { + if (task.result.sourceLang != null && task.result.targetLang != null) { + val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!) + showOriginal = preference == task.result.sourceLang + } + translatedTextState.value = task.result + } else { + translatedTextState.value = ResultOrError(content, null, null, null) } - } } - } } - } } - } -} \ No newline at end of file + + val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content + + Column(modifier = Modifier.padding(top = 5.dp)) { + ExpandableRichTextViewer( + toBeViewed, + canPreview, + modifier, + tags, + backgroundColor, + accountViewModel, + navController + ) + + val target = translatedTextState.value.targetLang + val source = translatedTextState.value.sourceLang + + if (source != null && target != null) { + if (source != target) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 5.dp) + ) { + val clickableTextStyle = + SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f)) + + val annotatedTranslationString = buildAnnotatedString { + withStyle(clickableTextStyle) { + pushStringAnnotation("langSettings", true.toString()) + append(stringResource(R.string.auto)) + } + + append("-${stringResource(R.string.translated_from)} ") + + withStyle(clickableTextStyle) { + pushStringAnnotation("showOriginal", true.toString()) + append(Locale(source).displayName) + } + + append(" ${stringResource(R.string.to)} ") + + withStyle(clickableTextStyle) { + pushStringAnnotation("showOriginal", false.toString()) + append(Locale(target).displayName) + } + } + + ClickableText( + text = annotatedTranslationString, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)), + overflow = TextOverflow.Visible, + maxLines = 3 + ) { spanOffset -> + annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset) + .firstOrNull() + ?.also { span -> + if (span.tag == "showOriginal") { + showOriginal = span.item.toBoolean() + } else { + langSettingsPopupExpanded = !langSettingsPopupExpanded + } + } + } + + DropdownMenu( + expanded = langSettingsPopupExpanded, + onDismissRequest = { langSettingsPopupExpanded = false } + ) { + DropdownMenuItem(onClick = { + accountViewModel.dontTranslateFrom(source, context) + langSettingsPopupExpanded = false + }) { + if (source in account.dontTranslateFrom) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text(stringResource(R.string.never_translate_from) + "${Locale(source).displayName}") + } + Divider() + DropdownMenuItem(onClick = { + accountViewModel.prefer(source, target, source) + langSettingsPopupExpanded = false + }) { + if (account.preferenceBetween(source, target) == source) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + "${stringResource(R.string.show_in)} ${Locale(source).displayName} ${ + stringResource( + R.string.first + ) + }" + ) + } + DropdownMenuItem(onClick = { + accountViewModel.prefer(source, target, target) + langSettingsPopupExpanded = false + }) { + if (account.preferenceBetween(source, target) == target) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + "${stringResource(R.string.show_in)} ${Locale(target).displayName} ${ + stringResource( + R.string.first + ) + }" + ) + } + Divider() + + val languageList = + ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { lang -> + DropdownMenuItem(onClick = { + accountViewModel.translateTo(lang, context) + langSettingsPopupExpanded = false + }) { + if (lang.language in account.translateTo) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text("${stringResource(R.string.always_translate_to)}${lang.displayName}") + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt index d4adfc34d6..2693f4a6fc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt @@ -16,56 +16,57 @@ import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext - @Composable fun UrlPreview(url: String, urlText: String) { - val default = UrlCachedPreviewer.cache[url]?.let { - if (it.url == url) - UrlPreviewState.Loaded(it) - else - UrlPreviewState.Empty + val default = UrlCachedPreviewer.cache[url]?.let { + if (it.url == url) { + UrlPreviewState.Loaded(it) + } else { + UrlPreviewState.Empty + } + } ?: UrlPreviewState.Loading + var context = LocalContext.current - } ?: UrlPreviewState.Loading - var context = LocalContext.current + var urlPreviewState by remember { mutableStateOf(UrlPreviewState.Loading) } - var urlPreviewState by remember { mutableStateOf(UrlPreviewState.Loading) } + // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). + LaunchedEffect(url) { + if (urlPreviewState == UrlPreviewState.Loading) { + withContext(Dispatchers.IO) { + UrlCachedPreviewer.previewInfo( + url, + object : IUrlPreviewCallback { + override fun onComplete(urlInfo: UrlInfoItem) { + if (urlInfo.allFetchComplete() && urlInfo.url == url) { + urlPreviewState = UrlPreviewState.Loaded(urlInfo) + } else { + urlPreviewState = UrlPreviewState.Empty + } + } - // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). - LaunchedEffect(url) { - if (urlPreviewState == UrlPreviewState.Loading) { - withContext(Dispatchers.IO) { - UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback { - override fun onComplete(urlInfo: UrlInfoItem) { - if (urlInfo.allFetchComplete() && urlInfo.url == url) - urlPreviewState = UrlPreviewState.Loaded(urlInfo) - else - urlPreviewState = UrlPreviewState.Empty - } - - override fun onFailed(throwable: Throwable) { - urlPreviewState = UrlPreviewState.Error( - context.getString( - R.string.error_parsing_preview_for, - url, - throwable.message - ) - ) - } - }) - } + override fun onFailed(throwable: Throwable) { + urlPreviewState = UrlPreviewState.Error( + context.getString( + R.string.error_parsing_preview_for, + url, + throwable.message + ) + ) + } + } + ) + } + } } - } - Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is UrlPreviewState.Loaded -> { - UrlPreviewCard(url, state.previewInfo) - } - else -> { - ClickableUrl(urlText, url) - } + Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is UrlPreviewState.Loaded -> { + UrlPreviewCard(url, state.previewInfo) + } + else -> { + ClickableUrl(urlText, url) + } + } } - } - } - diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index 52e0390703..8b4802ad6d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -25,51 +25,52 @@ import java.net.URL @Composable fun UrlPreviewCard( - url: String, - previewInfo: UrlInfoItem + url: String, + previewInfo: UrlInfoItem ) { - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - Row( - modifier = Modifier - .clickable { runCatching { uri.openUri(url) } } - .clip(shape = RoundedCornerShape(15.dp)) - .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) - ) { - Column { - // correctly treating relative images - val imageUrl = if (previewInfo.image.startsWith("/")) - URL(URL(previewInfo.url), previewInfo.image).toString() - else - previewInfo.image - - AsyncImage( - model = imageUrl, - contentDescription = stringResource(R.string.preview_card_image_for, url), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) - - Text( - text = previewInfo.title, - style = MaterialTheme.typography.body2, + Row( modifier = Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + .clickable { runCatching { uri.openUri(url) } } + .clip(shape = RoundedCornerShape(15.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) + ) { + Column { + // correctly treating relative images + val imageUrl = if (previewInfo.image.startsWith("/")) { + URL(URL(previewInfo.url), previewInfo.image).toString() + } else { + previewInfo.image + } - Text( - text = previewInfo.description, - style = MaterialTheme.typography.caption, - modifier = Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) + AsyncImage( + model = imageUrl, + contentDescription = stringResource(R.string.preview_card_image_for, url), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = previewInfo.title, + style = MaterialTheme.typography.body2, + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = previewInfo.description, + style = MaterialTheme.typography.caption, + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt index 1a99d16c61..ee0089e569 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt @@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.ui.components import com.baha.url.preview.UrlInfoItem sealed class UrlPreviewState { - object Loading: UrlPreviewState() - class Loaded(val previewInfo: UrlInfoItem): UrlPreviewState() - object Empty: UrlPreviewState() - class Error(val errorMessage: String): UrlPreviewState() + object Loading : UrlPreviewState() + class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState() + object Empty : UrlPreviewState() + class Error(val errorMessage: String) : UrlPreviewState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 0e07271eac..081bd720cb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -20,35 +20,36 @@ import com.vitorpamplona.amethyst.VideoCache @Composable fun VideoView(videoUri: String) { - val context = LocalContext.current + val context = LocalContext.current - val exoPlayer = remember { - ExoPlayer.Builder(context).build().apply { - repeatMode = Player.REPEAT_MODE_ALL - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_ALL + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + } } - } - DisposableEffect(exoPlayer) { - exoPlayer.setMediaSource( - ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri)) - ) - exoPlayer.prepare() - onDispose { - exoPlayer.release() - } - } - - AndroidView( - modifier = Modifier.fillMaxWidth(), - factory = { - StyledPlayerView(context).apply { - player = exoPlayer - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT + DisposableEffect(exoPlayer) { + exoPlayer.setMediaSource( + ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri)) ) - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - } - }) -} \ No newline at end of file + exoPlayer.prepare() + onDispose { + exoPlayer.release() + } + } + + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { + StyledPlayerView(context).apply { + player = exoPlayer + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + } + } + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt index 34cc1a4e15..b213c63992 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt @@ -57,7 +57,7 @@ fun ZoomableAsyncImage(imageUrl: String) { scaleY = scale, translationX = offsetX, translationY = offsetY - ), + ) ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt index 7e705f0a6f..b2196de826 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt @@ -34,56 +34,56 @@ import com.vitorpamplona.amethyst.ui.actions.SaveToGallery @Composable @OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) fun ZoomableImageView(word: String) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboardManager.current - // store the dialog open or close state - var dialogOpen by remember { - mutableStateOf(false) - } + // store the dialog open or close state + var dialogOpen by remember { + mutableStateOf(false) + } - AsyncImage( - model = word, - contentDescription = word, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) - .combinedClickable( - onClick = { dialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(word)) }, - ) - ) + AsyncImage( + model = word, + contentDescription = word, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) + .combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(word)) } + ) + ) - if (dialogOpen) { - ZoomableImageDialog(word, onDismiss = { dialogOpen = false }) - } + if (dialogOpen) { + ZoomableImageDialog(word, onDismiss = { dialogOpen = false }) + } } @Composable fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - Column( - modifier = Modifier.padding(10.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = onDismiss) + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + Column( + modifier = Modifier.padding(10.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = onDismiss) - SaveToGallery(url = imageUrl) + SaveToGallery(url = imageUrl) + } + + ZoomableAsyncImage(imageUrl) + } } - - ZoomableAsyncImage(imageUrl) - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index 388d6ce0ca..6e19217dd7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -5,21 +5,21 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -object ChannelFeedFilter: FeedFilter() { - lateinit var account: Account - lateinit var channel: Channel +object ChannelFeedFilter : FeedFilter() { + lateinit var account: Account + lateinit var channel: Channel - fun loadMessagesBetween(accountLoggedIn: Account, channelId: String) { - account = accountLoggedIn - channel = LocalCache.getOrCreateChannel(channelId) - } + fun loadMessagesBetween(accountLoggedIn: Account, channelId: String) { + account = accountLoggedIn + channel = LocalCache.getOrCreateChannel(channelId) + } - // returns the last Note of each user. - override fun feed(): List { - return channel.notes - .values - .filter { account.isAcceptable(it) } - .sortedBy { it.createdAt() } - .reversed() - } + // returns the last Note of each user. + override fun feed(): List { + return channel.notes + .values + .filter { account.isAcceptable(it) } + .sortedBy { it.createdAt() } + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index 9ffad12052..9d77e1ac41 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -5,29 +5,29 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -object ChatroomFeedFilter: FeedFilter() { - var account: Account? = null - var withUser: User? = null +object ChatroomFeedFilter : FeedFilter() { + var account: Account? = null + var withUser: User? = null - fun loadMessagesBetween(accountIn: Account, userId: String) { - account = accountIn - withUser = LocalCache.checkGetOrCreateUser(userId) - } + fun loadMessagesBetween(accountIn: Account, userId: String) { + account = accountIn + withUser = LocalCache.checkGetOrCreateUser(userId) + } - // returns the last Note of each user. - override fun feed(): List { - val myAccount = account - val myUser = withUser + // returns the last Note of each user. + override fun feed(): List { + val myAccount = account + val myUser = withUser - if (myAccount == null || myUser == null) return emptyList() + if (myAccount == null || myUser == null) return emptyList() - val messages = myAccount - .userProfile() - .privateChatrooms[myUser] ?: return emptyList() + val messages = myAccount + .userProfile() + .privateChatrooms[myUser] ?: return emptyList() - return messages.roomMessages - .filter { myAccount.isAcceptable(it) } - .sortedBy { it.createdAt() } - .reversed() - } + return messages.roomMessages + .filter { myAccount.isAcceptable(it) } + .sortedBy { it.createdAt() } + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index b8946f387c..ddafd9e4a3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -3,36 +3,35 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -object ChatroomListKnownFeedFilter: FeedFilter() { - lateinit var account: Account +object ChatroomListKnownFeedFilter : FeedFilter() { + lateinit var account: Account - // returns the last Note of each user. - override fun feed(): List { - val me = account.userProfile() + // returns the last Note of each user. + override fun feed(): List { + val me = account.userProfile() - val privateChatrooms = account.userProfile().privateChatrooms - val messagingWith = privateChatrooms.keys.filter { - me.hasSentMessagesTo(it) && account.isAcceptable(it) + val privateChatrooms = account.userProfile().privateChatrooms + val messagingWith = privateChatrooms.keys.filter { + me.hasSentMessagesTo(it) && account.isAcceptable(it) + } + + val privateMessages = messagingWith.mapNotNull { it -> + privateChatrooms[it] + ?.roomMessages + ?.sortedBy { it.createdAt() } + ?.lastOrNull { it.event != null } + } + + val publicChannels = account.followingChannels().map { it -> + it.notes.values + .filter { account.isAcceptable(it) } + .sortedBy { it.createdAt() } + .lastOrNull { it.event != null } + } + + return (privateMessages + publicChannels) + .filterNotNull() + .sortedBy { it.createdAt() } + .reversed() } - - val privateMessages = messagingWith.mapNotNull { it -> - privateChatrooms[it] - ?.roomMessages - ?.sortedBy { it.createdAt() } - ?.lastOrNull { it.event != null } - } - - val publicChannels = account.followingChannels().map { it -> - it.notes.values - .filter { account.isAcceptable(it) } - .sortedBy { it.createdAt() } - .lastOrNull { it.event != null } - } - - return (privateMessages + publicChannels) - .filterNotNull() - .sortedBy { it.createdAt() } - .reversed() - } - } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index 7564c6077c..d53b689d38 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -3,27 +3,27 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -object ChatroomListNewFeedFilter: FeedFilter() { - lateinit var account: Account +object ChatroomListNewFeedFilter : FeedFilter() { + lateinit var account: Account - // returns the last Note of each user. - override fun feed(): List { - val me = ChatroomListKnownFeedFilter.account.userProfile() + // returns the last Note of each user. + override fun feed(): List { + val me = ChatroomListKnownFeedFilter.account.userProfile() - val privateChatrooms = account.userProfile().privateChatrooms - val messagingWith = privateChatrooms.keys.filter { - !me.hasSentMessagesTo(it) && account.isAcceptable(it) + val privateChatrooms = account.userProfile().privateChatrooms + val messagingWith = privateChatrooms.keys.filter { + !me.hasSentMessagesTo(it) && account.isAcceptable(it) + } + + val privateMessages = messagingWith.mapNotNull { it -> + privateChatrooms[it] + ?.roomMessages + ?.sortedBy { it.createdAt() } + ?.lastOrNull { it.event != null } + } + + return privateMessages + .sortedBy { it.createdAt() } + .reversed() } - - val privateMessages = messagingWith.mapNotNull { it -> - privateChatrooms[it] - ?.roomMessages - ?.sortedBy { it.createdAt() } - ?.lastOrNull { it.event != null } - } - - return privateMessages - .sortedBy { it.createdAt() } - .reversed() - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index 5907defab6..e5f78f4e26 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -5,15 +5,15 @@ import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue abstract class FeedFilter() { - @OptIn(ExperimentalTime::class) - fun loadTop(): List { - val (feed, elapsed) = measureTimedValue { - feed().take(1000) + @OptIn(ExperimentalTime::class) + fun loadTop(): List { + val (feed, elapsed) = measureTimedValue { + feed().take(1000) + } + + Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects") + return feed } - Log.d("Time", "${this.javaClass.simpleName} Feed in ${elapsed} with ${feed.size} objects") - return feed - } - - abstract fun feed(): List + abstract fun feed(): List } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index bc3866a621..5eda2e560e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -7,22 +7,21 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object GlobalFeedFilter: FeedFilter() { - lateinit var account: Account - - override fun feed() = LocalCache.notes.values - .filter { - (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) - && it.replyTo.isNullOrEmpty() - } - .filter { - // does not show events already in the public chat list - (it.channel() == null || it.channel() !in account.followingChannels()) - // does not show people the user already follows - && (it.author !in account.userProfile().follows) - } - .filter { account.isAcceptable(it) } - .sortedBy { it.createdAt() } - .reversed() +object GlobalFeedFilter : FeedFilter() { + lateinit var account: Account + override fun feed() = LocalCache.notes.values + .filter { + (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) && + it.replyTo.isNullOrEmpty() + } + .filter { + // does not show events already in the public chat list + (it.channel() == null || it.channel() !in account.followingChannels()) && + // does not show people the user already follows + (it.author !in account.userProfile().follows) + } + .filter { account.isAcceptable(it) } + .sortedBy { it.createdAt() } + .reversed() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt index 156b9239da..7025190a67 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt @@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -object HiddenAccountsFeedFilter: FeedFilter() { - lateinit var account: Account +object HiddenAccountsFeedFilter : FeedFilter() { + lateinit var account: Account - override fun feed() = account.hiddenUsers() + override fun feed() = account.hiddenUsers() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 5fd9e487cd..8b2101ce16 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -6,21 +6,21 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HomeConversationsFeedFilter: FeedFilter() { - lateinit var account: Account +object HomeConversationsFeedFilter : FeedFilter() { + lateinit var account: Account - override fun feed(): List { - val user = account.userProfile() + override fun feed(): List { + val user = account.userProfile() - return LocalCache.notes.values - .filter { - (it.event is TextNoteEvent || it.event is RepostEvent) - && it.author in user.follows - // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable - && it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true - && !it.isNewThread() - } - .sortedBy { it.createdAt() } - .reversed() - } + return LocalCache.notes.values + .filter { + (it.event is TextNoteEvent || it.event is RepostEvent) && + it.author in user.follows && + // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable + it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true && + !it.isNewThread() + } + .sortedBy { it.createdAt() } + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 3abb99e4d4..45b220dd96 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -7,32 +7,32 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HomeNewThreadFeedFilter: FeedFilter() { - lateinit var account: Account +object HomeNewThreadFeedFilter : FeedFilter() { + lateinit var account: Account - override fun feed(): List { - val user = account.userProfile() + override fun feed(): List { + val user = account.userProfile() - val notes = LocalCache.notes.values - .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) - && it.author in user.follows - // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable - && it.author?.let { !account.isHidden(it) } ?: true - && it.isNewThread() - } + val notes = LocalCache.notes.values + .filter { it -> + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) && + it.author in user.follows && + // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable + it.author?.let { !account.isHidden(it) } ?: true && + it.isNewThread() + } - val longFormNotes = LocalCache.addressables.values - .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) - && it.author in user.follows - // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable - && it.author?.let { !account.isHidden(it) } ?: true - && it.isNewThread() - } + val longFormNotes = LocalCache.addressables.values + .filter { it -> + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) && + it.author in user.follows && + // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable + it.author?.let { !account.isHidden(it) } ?: true && + it.isNewThread() + } - return (notes + longFormNotes) - .sortedBy { it.createdAt() } - .reversed() - } + return (notes + longFormNotes) + .sortedBy { it.createdAt() } + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 3489813291..707d93a50c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -4,41 +4,41 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.* -object NotificationFeedFilter: FeedFilter() { - lateinit var account: Account +object NotificationFeedFilter : FeedFilter() { + lateinit var account: Account - override fun feed(): List { - return account.userProfile() - .taggedPosts - .asSequence() - .filter { - it.author == null - || (!account.isHidden(it.author!!) && it.author != account.userProfile()) - } - .filter { - it.event !is ChannelCreateEvent - && it.event !is ChannelMetadataEvent - && it.event !is LnZapRequestEvent - && it.event !is BadgeDefinitionEvent - && it.event !is BadgeProfilesEvent - } - .filter { it -> - it.event !is TextNoteEvent - || it.replyTo?.any { it.author == account.userProfile() } == true - || account.userProfile() in it.directlyCiteUsers() - } - .filter { - it.event !is ReactionEvent - || it.replyTo?.lastOrNull()?.author == account.userProfile() - || account.userProfile() in it.directlyCiteUsers() - } - .filter { - it.event !is RepostEvent - || it.replyTo?.lastOrNull()?.author == account.userProfile() - || account.userProfile() in it.directlyCiteUsers() - } - .sortedBy { it.createdAt() } - .toList() - .reversed() - } + override fun feed(): List { + return account.userProfile() + .taggedPosts + .asSequence() + .filter { + it.author == null || + (!account.isHidden(it.author!!) && it.author != account.userProfile()) + } + .filter { + it.event !is ChannelCreateEvent && + it.event !is ChannelMetadataEvent && + it.event !is LnZapRequestEvent && + it.event !is BadgeDefinitionEvent && + it.event !is BadgeProfilesEvent + } + .filter { it -> + it.event !is TextNoteEvent || + it.replyTo?.any { it.author == account.userProfile() } == true || + account.userProfile() in it.directlyCiteUsers() + } + .filter { + it.event !is ReactionEvent || + it.replyTo?.lastOrNull()?.author == account.userProfile() || + account.userProfile() in it.directlyCiteUsers() + } + .filter { + it.event !is RepostEvent || + it.replyTo?.lastOrNull()?.author == account.userProfile() || + account.userProfile() in it.directlyCiteUsers() + } + .sortedBy { it.createdAt() } + .toList() + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt index 3d49eee10b..e82baf0f0e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt @@ -3,19 +3,19 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ThreadAssembler -object ThreadFeedFilter: FeedFilter() { - var noteId: String? = null +object ThreadFeedFilter : FeedFilter() { + var noteId: String? = null - override fun feed(): List { - val cachedSignatures: MutableMap = mutableMapOf() - val eventsToWatch = noteId?.let { ThreadAssembler().findThreadFor(it) } ?: emptySet() - // Currently orders by date of each event, descending, at each level of the reply stack - val order = compareByDescending { it.replyLevelSignature(cachedSignatures) } + override fun feed(): List { + val cachedSignatures: MutableMap = mutableMapOf() + val eventsToWatch = noteId?.let { ThreadAssembler().findThreadFor(it) } ?: emptySet() + // Currently orders by date of each event, descending, at each level of the reply stack + val order = compareByDescending { it.replyLevelSignature(cachedSignatures) } - return eventsToWatch.sortedWith(order) - } + return eventsToWatch.sortedWith(order) + } - fun loadThread(noteId: String?) { - this.noteId = noteId - } + fun loadThread(noteId: String?) { + this.noteId = noteId + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt index 4e7abefcc4..27ac42c82a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt @@ -5,19 +5,19 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -object UserProfileConversationsFeedFilter: FeedFilter() { - var account: Account? = null - var user: User? = null +object UserProfileConversationsFeedFilter : FeedFilter() { + var account: Account? = null + var user: User? = null - fun loadUserProfile(accountLoggedIn: Account, userId: String) { - account = accountLoggedIn - user = LocalCache.checkGetOrCreateUser(userId) - } + fun loadUserProfile(accountLoggedIn: Account, userId: String) { + account = accountLoggedIn + user = LocalCache.checkGetOrCreateUser(userId) + } - override fun feed(): List { - return user?.notes - ?.filter { account?.isAcceptable(it) == true && !it.isNewThread() } - ?.sortedBy { it.createdAt() } - ?.reversed() ?: emptyList() - } + override fun feed(): List { + return user?.notes + ?.filter { account?.isAcceptable(it) == true && !it.isNewThread() } + ?.sortedBy { it.createdAt() } + ?.reversed() ?: emptyList() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt index f76fc4f14f..f25ecc17b9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt @@ -4,17 +4,17 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -object UserProfileFollowersFeedFilter: FeedFilter() { - lateinit var account: Account - var user: User? = null +object UserProfileFollowersFeedFilter : FeedFilter() { + lateinit var account: Account + var user: User? = null - fun loadUserProfile(accountLoggedIn: Account, userId: String) { - account = accountLoggedIn - user = LocalCache.users[userId] - } + fun loadUserProfile(accountLoggedIn: Account, userId: String) { + account = accountLoggedIn + user = LocalCache.users[userId] + } - override fun feed(): List { - return user?.followers - ?.filter { account.isAcceptable(it) } ?: emptyList() - } + override fun feed(): List { + return user?.followers + ?.filter { account.isAcceptable(it) } ?: emptyList() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt index af3b9490e8..4249adff90 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt @@ -4,18 +4,18 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -object UserProfileFollowsFeedFilter: FeedFilter() { - lateinit var account: Account - var user: User? = null +object UserProfileFollowsFeedFilter : FeedFilter() { + lateinit var account: Account + var user: User? = null - fun loadUserProfile(accountLoggedIn: Account, userId: String) { - account = accountLoggedIn - user = LocalCache.users[userId] - } + fun loadUserProfile(accountLoggedIn: Account, userId: String) { + account = accountLoggedIn + user = LocalCache.users[userId] + } - override fun feed(): List { - return user?.follows - ?.filter { account.isAcceptable(it) } - ?.reversed() ?: emptyList() - } + override fun feed(): List { + return user?.follows + ?.filter { account.isAcceptable(it) } + ?.reversed() ?: emptyList() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 791b3263c7..138e082177 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -5,22 +5,22 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -object UserProfileNewThreadFeedFilter: FeedFilter() { - var account: Account? = null - var user: User? = null +object UserProfileNewThreadFeedFilter : FeedFilter() { + var account: Account? = null + var user: User? = null - fun loadUserProfile(accountLoggedIn: Account, userId: String) { - account = accountLoggedIn - user = LocalCache.checkGetOrCreateUser(userId) - } + fun loadUserProfile(accountLoggedIn: Account, userId: String) { + account = accountLoggedIn + user = LocalCache.checkGetOrCreateUser(userId) + } - override fun feed(): List { - val longFormNotes = LocalCache.addressables.values.filter { it.author == user } + override fun feed(): List { + val longFormNotes = LocalCache.addressables.values.filter { it.author == user } - return user?.notes - ?.plus(longFormNotes) - ?.filter { account?.isAcceptable(it) == true && it.isNewThread() } - ?.sortedBy { it.createdAt() } - ?.reversed() ?: emptyList() - } + return user?.notes + ?.plus(longFormNotes) + ?.filter { account?.isAcceptable(it) == true && it.isNewThread() } + ?.sortedBy { it.createdAt() } + ?.reversed() ?: emptyList() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt index c285b93344..025143a494 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt @@ -4,18 +4,18 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -object UserProfileReportsFeedFilter: FeedFilter() { - var user: User? = null +object UserProfileReportsFeedFilter : FeedFilter() { + var user: User? = null - fun loadUserProfile(userId: String) { - user = LocalCache.checkGetOrCreateUser(userId) - } + fun loadUserProfile(userId: String) { + user = LocalCache.checkGetOrCreateUser(userId) + } - override fun feed(): List { - return user?.reports - ?.values - ?.flatten() - ?.sortedBy { it.createdAt() } - ?.reversed() ?: emptyList() - } + override fun feed(): List { + return user?.reports + ?.values + ?.flatten() + ?.sortedBy { it.createdAt() } + ?.reversed() ?: emptyList() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt index 3531eef354..2e6b6ee7dc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt @@ -3,20 +3,16 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.zaps.UserZaps -object UserProfileZapsFeedFilter: FeedFilter>() { - var user: User? = null +object UserProfileZapsFeedFilter : FeedFilter>() { + var user: User? = null - fun loadUserProfile(userId: String) { - user = LocalCache.checkGetOrCreateUser(userId) - } + fun loadUserProfile(userId: String) { + user = LocalCache.checkGetOrCreateUser(userId) + } - override fun feed(): List> { - return (user?.zaps - ?.filter { it.value != null } - ?.toList() - ?.sortedBy { (it.second?.event as? LnZapEvent)?.amount } - ?.reversed() ?: emptyList()) as List> - } + override fun feed(): List> { + return UserZaps.forProfileFeed(user?.zaps) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index d7879648f7..b83f061557 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -103,7 +103,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView onClick = { coroutineScope.launch { if (currentRoute != item.route) { - navController.navigate(item.route){ + navController.navigate(item.route) { navController.graph.startDestinationRoute?.let { start -> popUpTo(start) restoreState = true @@ -113,7 +113,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView } } else { // TODO: Make it scrool to the top - navController.navigate(item.route){ + navController.navigate(item.route) { navController.graph.startDestinationRoute?.let { start -> popUpTo(start) { inclusive = item.route == Route.Home.route } restoreState = true diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 1d97af2a97..76e7164ed6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -23,4 +23,4 @@ fun AppNavigation( if (nextPage != null) { navController.navigate(nextPage) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 0b845a657e..44e9782094 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -68,12 +68,11 @@ import kotlinx.coroutines.launch @Composable fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { when (currentRoute(navController)) { - //Route.Profile.route -> TopBarWithBackButton(navController) + // Route.Profile.route -> TopBarWithBackButton(navController) else -> MainTopBar(scaffoldState, accountViewModel) } } - @Composable fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -95,8 +94,9 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) mutableStateOf(false) } - if (wantsToEditRelays) + if (wantsToEditRelays) { NewRelayListView({ wantsToEditRelays = false }, account) + } Column() { TopAppBar( @@ -119,9 +119,9 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) IconButton( onClick = { Client.allSubscriptions().map { - "${it} ${ - Client.getSubscriptionFilters(it) - .joinToString { it.filter.toJson() } + "$it ${ + Client.getSubscriptionFilters(it) + .joinToString { it.filter.toJson() } }" }.forEach { Log.d("CURRENT FILTERS", it) @@ -146,11 +146,11 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) println("Connected Relays: " + RelayPool.connectedRelays()) val imageLoader = Coil.imageLoader(context) - println("Image Disk Cache ${(imageLoader.diskCache?.size ?: 0)/(1024*1024)}/${(imageLoader.diskCache?.maxSize ?: 0)/(1024*1024)} MB") - println("Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0)/(1024*1024)}/${(imageLoader.memoryCache?.maxSize ?: 0)/(1024*1024)} MB") + println("Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB") + println("Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB") - println("Notes: " + LocalCache.notes.filter { it.value.event != null }.size +"/"+ LocalCache.notes.size) - println("Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size +"/"+ LocalCache.users.size) + println("Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size) + println("Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size) } ) { Icon( @@ -166,7 +166,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) modifier = Modifier .fillMaxWidth() .fillMaxHeight(), - horizontalAlignment = Alignment.End, + horizontalAlignment = Alignment.End ) { Row( @@ -206,13 +206,14 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) modifier = Modifier .width(34.dp) .height(34.dp) - .clip(shape = CircleShape), + .clip(shape = CircleShape) ) } }, actions = { IconButton( - onClick = { wantsToEditRelays = true }, modifier = Modifier + onClick = { wantsToEditRelays = true }, + modifier = Modifier ) { Icon( painter = painterResource(R.drawable.ic_trends), @@ -253,4 +254,4 @@ fun TopBarWithBackButton(navController: NavHostController) { ) Divider(thickness = 0.25.dp) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index b67fac0cad..f9c3c1648e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -56,11 +56,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.launch @Composable -fun DrawerContent(navController: NavHostController, - scaffoldState: ScaffoldState, - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel) { - +fun DrawerContent( + navController: NavHostController, + scaffoldState: ScaffoldState, + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel +) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -90,7 +91,7 @@ fun DrawerContent(navController: NavHostController, .fillMaxWidth() .weight(1F), accountStateViewModel, - account, + account ) BottomContent(account.userProfile(), scaffoldState, navController) @@ -172,7 +173,9 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol ) } if (accountUser.bestUsername() != null) { - Text(" @${accountUser.bestUsername()}", color = Color.LightGray, + Text( + " @${accountUser.bestUsername()}", + color = Color.LightGray, modifier = Modifier .padding(top = 15.dp) .clickable(onClick = { @@ -185,16 +188,18 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol }) ) } - Row(modifier = Modifier - .padding(top = 15.dp) - .clickable(onClick = { - accountUser.let { - navController.navigate("User/${it.pubkeyHex}") - } - coroutineScope.launch { - scaffoldState.drawerState.close() - } - })) { + Row( + modifier = Modifier + .padding(top = 15.dp) + .clickable(onClick = { + accountUser.let { + navController.navigate("User/${it.pubkeyHex}") + } + coroutineScope.launch { + scaffoldState.drawerState.close() + } + }) + ) { Row() { Text("${accountUserFollows.follows.size}", fontWeight = FontWeight.Bold) Text(stringResource(R.string.following)) @@ -215,21 +220,21 @@ fun ListContent( scaffoldState: ScaffoldState, modifier: Modifier, accountViewModel: AccountStateViewModel, - account: Account, + account: Account ) { - val coroutineScope = rememberCoroutineScope() var backupDialogOpen by remember { mutableStateOf(false) } Column(modifier = modifier.fillMaxHeight()) { - if (accountUser != null) + if (accountUser != null) { NavigationRow( title = stringResource(R.string.profile), icon = Route.Profile.icon, tint = MaterialTheme.colors.primary, navController = navController, scaffoldState = scaffoldState, - route = "User/${accountUser.pubkeyHex}", + route = "User/${accountUser.pubkeyHex}" ) + } Divider(thickness = 0.25.dp) @@ -239,7 +244,7 @@ fun ListContent( tint = MaterialTheme.colors.onBackground, navController = navController, scaffoldState = scaffoldState, - route = Route.Filters.route, + route = Route.Filters.route ) Divider(thickness = 0.25.dp) @@ -273,7 +278,7 @@ fun NavigationRow( tint: Color, navController: NavHostController, scaffoldState: ScaffoldState, - route: String, + route: String ) { val coroutineScope = rememberCoroutineScope() val currentRoute = currentRoute(navController) @@ -289,9 +294,10 @@ fun NavigationRow( @Composable fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit) { - Row(modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) ) { Row( modifier = Modifier @@ -300,14 +306,15 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { Icon( - painter = painterResource(icon), null, + painter = painterResource(icon), + null, modifier = Modifier.size(22.dp), tint = tint ) Text( modifier = Modifier.padding(start = 16.dp), text = title, - fontSize = 18.sp, + fontSize = 18.sp ) } } @@ -335,9 +342,9 @@ fun BottomContent(user: User, scaffoldState: ScaffoldState, navController: NavCo ) { Text( modifier = Modifier.padding(start = 16.dp), - text = "v"+BuildConfig.VERSION_NAME, + text = "v" + BuildConfig.VERSION_NAME, fontSize = 12.sp, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Bold ) /* IconButton( @@ -374,7 +381,8 @@ fun BottomContent(user: User, scaffoldState: ScaffoldState, navController: NavCo } if (dialogOpen) { - ShowQRDialog(user, + ShowQRDialog( + user, onScan = { dialogOpen = false coroutineScope.launch { @@ -386,5 +394,3 @@ fun BottomContent(user: User, scaffoldState: ScaffoldState, navController: NavCo ) } } - - diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 1f6e7805ea..66bed57fac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -28,53 +28,70 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen - sealed class Route( val route: String, val icon: Int, - val hasNewItems: (Account, NotificationCache, Context) -> Boolean = { _,_,_ -> false }, + val hasNewItems: (Account, NotificationCache, Context) -> Boolean = { _, _, _ -> false }, val arguments: List = emptyList(), val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit ) { - object Home : Route("Home", R.drawable.ic_home, + object Home : Route( + "Home", + R.drawable.ic_home, hasNewItems = { acc, cache, ctx -> homeHasNewItems(acc, cache, ctx) }, buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } } ) - object Search : Route("Search", R.drawable.ic_globe, - buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }} + object Search : Route( + "Search", + R.drawable.ic_globe, + buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) } } ) - object Notification : Route("Notification", R.drawable.ic_notifications, + object Notification : Route( + "Notification", + R.drawable.ic_notifications, hasNewItems = { acc, cache, ctx -> notificationHasNewItems(acc, cache, ctx) }, - buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }} + buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) } } ) - object Message : Route("Message", R.drawable.ic_dm, + object Message : Route( + "Message", + R.drawable.ic_dm, hasNewItems = { acc, cache, ctx -> messagesHasNewItems(acc, cache, ctx) }, - buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }} + buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) } } ) - object Filters : Route("Filters", R.drawable.ic_security, - buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) }} + object Filters : Route( + "Filters", + R.drawable.ic_security, + buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) } } ) - object Profile : Route("User/{id}", R.drawable.ic_profile, - arguments = listOf(navArgument("id") { type = NavType.StringType } ), - buildScreen = { acc, accSt, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) }} + object Profile : Route( + "User/{id}", + R.drawable.ic_profile, + arguments = listOf(navArgument("id") { type = NavType.StringType }), + buildScreen = { acc, accSt, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) } } ) - object Note : Route("Note/{id}", R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType } ), - buildScreen = { acc, accSt, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) }} + object Note : Route( + "Note/{id}", + R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }), + buildScreen = { acc, accSt, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) } } ) - object Room : Route("Room/{id}", R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType } ), - buildScreen = { acc, accSt, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) }} + object Room : Route( + "Room/{id}", + R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }), + buildScreen = { acc, accSt, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) } } ) - object Channel : Route("Channel/{id}", R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType } ), - buildScreen = { acc, accSt, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, accSt, nav) }} + object Channel : Route( + "Channel/{id}", + R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }), + buildScreen = { acc, accSt, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, accSt, nav) } } ) } @@ -85,7 +102,7 @@ val Routes = listOf( Route.Search, Route.Notification, - //drawer + // drawer Route.Profile, Route.Note, Route.Room, @@ -93,9 +110,9 @@ val Routes = listOf( Route.Filters ) -//** -//* Functions below only exist because we have not broken the datasource classes into backend and frontend. -//** +// ** +// * Functions below only exist because we have not broken the datasource classes into backend and frontend. +// ** @Composable public fun currentRoute(navController: NavHostController): String? { val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -128,4 +145,4 @@ private fun messagesHasNewItems(account: Account, cache: NotificationCache, cont val lastTime = cache.load("Room/${note.author?.pubkeyHex}", context) return (note.createdAt() ?: 0) > lastTime -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 20f31f5b93..7cbf6a4327 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -14,8 +14,6 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Badge -import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.MilitaryTech import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,20 +24,16 @@ 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.compositeOver import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.ui.screen.BadgeCard -import com.vitorpamplona.amethyst.ui.screen.LikeSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @OptIn(ExperimentalFoundationApi::class) @@ -77,7 +71,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, modifier: Modifier = Modifier, isInnerN modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}"){ + navController.navigate("Note/${note.idHex}") { launchSingleTop = true } } else { @@ -89,18 +83,21 @@ fun BadgeCompose(likeSetCard: BadgeCard, modifier: Modifier = Modifier, isInnerN onLongClick = { popupExpanded = true } ) ) { - Row(modifier = Modifier - .padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp) + Row( + modifier = Modifier + .padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp + ) ) { - // Draws the like picture outside the boosted card. if (!isInnerNote) { - Box(modifier = Modifier - .width(55.dp) - .padding(0.dp)) { + Box( + modifier = Modifier + .width(55.dp) + .padding(0.dp) + ) { Icon( imageVector = Icons.Default.MilitaryTech, null, @@ -138,4 +135,4 @@ fun BadgeCompose(likeSetCard: BadgeCard, modifier: Modifier = Modifier, isInnerN } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 44f54722f8..e25b5ba262 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -25,83 +25,82 @@ import com.vitorpamplona.amethyst.model.User @Composable fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) { - Column(modifier = modifier) { - Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) { - Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) { - Row( - modifier = Modifier.padding( - start = 20.dp, - end = 20.dp, - bottom = 8.dp, - top = 15.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(R.string.post_not_found), - modifier = Modifier.padding(30.dp), - color = Color.Gray, - ) + Column(modifier = modifier) { + Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) { + Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) { + Row( + modifier = Modifier.padding( + start = 20.dp, + end = 20.dp, + bottom = 8.dp, + top = 15.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.post_not_found), + modifier = Modifier.padding(30.dp), + color = Color.Gray + ) + } + + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = 0.25.dp + ) + } } - - Divider( - modifier = Modifier.padding(vertical = 10.dp), - thickness = 0.25.dp - ) - } } - } } - @Composable fun HiddenNote(reports: Set, loggedIn: User, modifier: Modifier = Modifier, isQuote: Boolean = false, navController: NavController, onClick: () -> Unit) { - Column(modifier = modifier) { - Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) { - Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) { - Row( - modifier = Modifier.padding( - start = 20.dp, - end = 20.dp - ), - verticalAlignment = Alignment.CenterVertically - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(30.dp)) { - Text( - text = stringResource(R.string.post_was_flagged_as_inappropriate_by), - color = Color.Gray, - ) - FlowRow(modifier = Modifier.padding(top = 10.dp)) { - reports.forEach { - NoteAuthorPicture( - note = it, - navController = navController, - userAccount = loggedIn, - size = 35.dp + Column(modifier = modifier) { + Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) { + Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) { + Row( + modifier = Modifier.padding( + start = 20.dp, + end = 20.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(30.dp)) { + Text( + text = stringResource(R.string.post_was_flagged_as_inappropriate_by), + color = Color.Gray + ) + FlowRow(modifier = Modifier.padding(top = 10.dp)) { + reports.forEach { + NoteAuthorPicture( + note = it, + navController = navController, + userAccount = loggedIn, + size = 35.dp + ) + } + } + + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = onClick, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ), + contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(R.string.show_anyway), color = Color.White) + } + } + } + + Divider( + thickness = 0.25.dp ) - } } - - Button( - modifier = Modifier.padding(top = 10.dp), - onClick = onClick, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ), - contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) - ) { - Text(text = stringResource(R.string.show_anyway), color = Color.White) - } - } } - - Divider( - thickness = 0.25.dp - ) - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt index 5462d4ef46..718bb233e2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt @@ -68,7 +68,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}"){ + navController.navigate("Note/${note.idHex}") { launchSingleTop = true } } else { @@ -80,18 +80,21 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro onLongClick = { popupExpanded = true } ) ) { - Row(modifier = Modifier - .padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp) + Row( + modifier = Modifier + .padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp + ) ) { - // Draws the like picture outside the boosted card. if (!isInnerNote) { - Box(modifier = Modifier - .width(55.dp) - .padding(0.dp)) { + Box( + modifier = Modifier + .width(55.dp) + .padding(0.dp) + ) { Icon( painter = painterResource(R.drawable.ic_retweeted), null, @@ -128,4 +131,4 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt index 4a1727088c..4bc7947665 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -77,7 +77,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr } else if (noteEvent is ChannelMetadataEvent) { "${stringResource(R.string.channel_information_changed_to)} " } else { - noteEvent?.content + noteEvent?.content() } channel?.let { channel -> var hasNewMessages by remember { mutableStateOf(false) } @@ -99,16 +99,16 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) ) Text( - " ${stringResource(R.string.public_chat)}", + " ${stringResource(R.string.public_chat)}", color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) }, channelLastTime = note.createdAt(), channelLastContent = "${author?.toBestDisplayName()}: " + description, hasNewMessages = hasNewMessages, - onClick = { navController.navigate("Channel/${channel.idHex}") }) + onClick = { navController.navigate("Channel/${channel.idHex}") } + ) } - } else { val replyAuthorBase = note.mentions?.first() @@ -127,7 +127,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr LaunchedEffect(key1 = notificationCache, key2 = note) { noteEvent?.let { - hasNewMessages = it.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context) + hasNewMessages = it.createdAt() > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context) } } @@ -137,10 +137,10 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr channelLastTime = note.createdAt(), channelLastContent = accountViewModel.decrypt(note), hasNewMessages = hasNewMessages, - onClick = { navController.navigate("Room/${user.pubkeyHex}") }) + onClick = { navController.navigate("Room/${user.pubkeyHex}") } + ) } } - } @Composable @@ -184,16 +184,17 @@ fun ChannelName( hasNewMessages: Boolean, onClick: () -> Unit ) { - val context = LocalContext.current - Column(modifier = Modifier.clickable(onClick = onClick) ) { + Column(modifier = Modifier.clickable(onClick = onClick)) { Row( modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp) ) { channelPicture() - Column(modifier = Modifier.padding(start = 10.dp), - verticalArrangement = Arrangement.SpaceAround) { + Column( + modifier = Modifier.padding(start = 10.dp), + verticalArrangement = Arrangement.SpaceAround + ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 4.dp) @@ -206,11 +207,10 @@ fun ChannelName( color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f) ) } - } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - if (channelLastContent != null) + if (channelLastContent != null) { Text( channelLastContent, color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f), @@ -219,7 +219,7 @@ fun ChannelName( style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), modifier = Modifier.weight(1f) ) - else + } else { Text( stringResource(R.string.referenced_event_not_found), color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f), @@ -227,6 +227,7 @@ fun ChannelName( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) + } if (hasNewMessages) { NewItemsBubble() @@ -263,4 +264,4 @@ fun NewItemsBubble() { .align(Alignment.Center) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 87f749abce..437755f994 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -79,7 +79,6 @@ fun ChatroomMessageCompose( navController: NavController, onWantsToReply: (Note) -> Unit ) { - val noteState by baseNote.live().metadata.observeAsState() val note = noteState?.note @@ -143,12 +142,16 @@ fun ChatroomMessageCompose( } Column() { - val modif = if (innerQuote) Modifier.padding(top = 10.dp, end = 5.dp) else Modifier.fillMaxWidth(1f).padding( - start = 12.dp, - end = 12.dp, - top = 5.dp, - bottom = 5.dp - ) + val modif = if (innerQuote) { + Modifier.padding(top = 10.dp, end = 5.dp) + } else { + Modifier.fillMaxWidth(1f).padding( + start = 12.dp, + end = 12.dp, + top = 5.dp, + bottom = 5.dp + ) + } Row( modifier = modif, @@ -161,9 +164,8 @@ fun ChatroomMessageCompose( horizontalArrangement = alignment, modifier = modif2.onSizeChanged { availableBubbleSize = it - }, + } ) { - Surface( color = backgroundBubbleColor, shape = shape, @@ -178,9 +180,8 @@ fun ChatroomMessageCompose( Column( modifier = Modifier.padding(start = 10.dp, end = 5.dp, bottom = 5.dp).onSizeChanged { bubbleSize = it - }, + } ) { - val authorState by note.author!!.live().metadata.observeAsState() val author = authorState?.user!! @@ -211,10 +212,10 @@ fun ChatroomMessageCompose( " ${author?.toBestDisplayName()}", fontWeight = FontWeight.Bold, modifier = Modifier.clickable(onClick = { - author?.let { - navController.navigate("User/${it.pubkeyHex}") - } - }) + author?.let { + navController.navigate("User/${it.pubkeyHex}") + } + }) ) } } @@ -223,7 +224,7 @@ fun ChatroomMessageCompose( if (!innerQuote && replyTo != null && replyTo.isNotEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { replyTo.toSet().mapIndexed { index, note -> - if (note.event != null) + if (note.event != null) { ChatroomMessageCompose( note, null, @@ -233,6 +234,7 @@ fun ChatroomMessageCompose( navController = navController, onWantsToReply = onWantsToReply ) + } } } } @@ -240,32 +242,46 @@ fun ChatroomMessageCompose( Row(verticalAlignment = Alignment.CenterVertically) { val event = note.event if (event is ChannelCreateEvent) { - Text(text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.created)} " + (event.channelInfo().name - ?: "") +" ${stringResource(R.string.with_description_of)} '" + (event.channelInfo().about - ?: "") + "', ${stringResource(R.string.and_picture)} '" + (event.channelInfo().picture - ?: "") + "'" + Text( + text = note.author?.toBestDisplayName() + .toString() + " ${stringResource(R.string.created)} " + ( + event.channelInfo().name + ?: "" + ) + " ${stringResource(R.string.with_description_of)} '" + ( + event.channelInfo().about + ?: "" + ) + "', ${stringResource(R.string.and_picture)} '" + ( + event.channelInfo().picture + ?: "" + ) + "'" ) } else if (event is ChannelMetadataEvent) { - Text(text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (event.channelInfo().name - ?: "") + "$', {stringResource(R.string.description_to)} '" + (event.channelInfo().about - ?: "") + "', ${stringResource(R.string.and_picture_to)} '" + (event.channelInfo().picture - ?: "") + "'" + Text( + text = note.author?.toBestDisplayName() + .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( + event.channelInfo().name + ?: "" + ) + "$', {stringResource(R.string.description_to)} '" + ( + event.channelInfo().about + ?: "" + ) + "', ${stringResource(R.string.and_picture_to)} '" + ( + event.channelInfo().picture + ?: "" + ) + "'" ) } else { val eventContent = accountViewModel.decrypt(note) - val canPreview = note.author == accountUser - || (note.author?.let { accountUser.isFollowing(it) } ?: true ) - || !noteForReports.hasAnyReports() + val canPreview = note.author == accountUser || + (note.author?.let { accountUser.isFollowing(it) } ?: true) || + !noteForReports.hasAnyReports() if (eventContent != null) { TranslateableRichTextViewer( eventContent, canPreview, Modifier, - note.event?.tags, + note.event?.tags(), backgroundBubbleColor, accountViewModel, navController @@ -275,7 +291,7 @@ fun ChatroomMessageCompose( stringResource(R.string.could_not_decrypt_the_message), true, Modifier, - note.event?.tags, + note.event?.tags(), backgroundBubbleColor, accountViewModel, navController @@ -343,9 +359,10 @@ private fun RelayBadges(baseNote: Note) { Box( Modifier .size(15.dp) - .padding(1.dp)) { + .padding(1.dp) + ) { AsyncImage( - model = "https://${url}/favicon.ico", + model = "https://$url/favicon.ico", placeholder = BitmapPainter(RoboHashCache.get(ctx, url)), fallback = BitmapPainter(RoboHashCache.get(ctx, url)), error = BitmapPainter(RoboHashCache.get(ctx, url)), @@ -369,7 +386,7 @@ private fun RelayBadges(baseNote: Note) { imageVector = Icons.Default.ChevronRight, null, modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt index fdbec03b71..e25a270cf5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt @@ -68,7 +68,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}"){ + navController.navigate("Note/${note.idHex}") { launchSingleTop = true } } else { @@ -80,18 +80,21 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn onLongClick = { popupExpanded = true } ) ) { - Row(modifier = Modifier - .padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp) + Row( + modifier = Modifier + .padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp + ) ) { - // Draws the like picture outside the boosted card. if (!isInnerNote) { - Box(modifier = Modifier - .width(55.dp) - .padding(0.dp)) { + Box( + modifier = Modifier + .width(55.dp) + .padding(0.dp) + ) { Icon( painter = painterResource(R.drawable.ic_liked), null, @@ -128,4 +131,4 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 5cc2181d80..bc6a7db787 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -86,14 +86,15 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, modifier: Modifier = Modifier, r onLongClick = { popupExpanded = true } ) ) { - Row(modifier = Modifier - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp) + Row( + modifier = Modifier + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp + ) ) { Column(Modifier.fillMaxWidth()) { - if (multiSetCard.zapEvents.isNotEmpty()) { Row(Modifier.fillMaxWidth()) { // Draws the like picture outside the boosted card. @@ -192,9 +193,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, modifier: Modifier = Modifier, r } Row(Modifier.fillMaxWidth()) { - Box(modifier = Modifier - .width(65.dp) - .padding(0.dp)) { + Box( + modifier = Modifier + .width(65.dp) + .padding(0.dp) + ) { } NoteCompose( @@ -213,4 +216,4 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, modifier: Modifier = Modifier, r } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 229c97f0f0..e5e538c985 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -27,14 +27,14 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.Nip05Verifier import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserMetadata +import com.vitorpamplona.amethyst.service.Nip05Verifier import com.vitorpamplona.amethyst.ui.theme.Nip05 -import java.util.Date import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.util.Date @Composable fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State { @@ -81,8 +81,9 @@ fun ObserveDisplayNip05Status(baseNote: Note) { val note = noteState?.note ?: return val author = note.author - if (author != null) + if (author != null) { ObserveDisplayNip05Status(author) + } } @Composable @@ -94,16 +95,16 @@ fun ObserveDisplayNip05Status(baseUser: User) { user.nip05()?.let { nip05 -> if (nip05.split("@").size == 2) { - val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) Row(verticalAlignment = Alignment.CenterVertically) { - if (nip05.split("@")[0] != "_") + if (nip05.split("@")[0] != "_") { Text( text = AnnotatedString(nip05.split("@")[0]), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), maxLines = 1, overflow = TextOverflow.Ellipsis ) + } if (nip05Verified == null) { Icon( @@ -200,4 +201,4 @@ fun DisplayNip05ProfileStatus(user: User) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index c96ff6212c..045887019d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -37,7 +36,6 @@ import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.RoboHashCache -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent @@ -49,17 +47,14 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer -import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.Following -import kotlin.time.ExperimentalTime -import com.vitorpamplona.amethyst.service.model.PrivateDmEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader +import com.vitorpamplona.amethyst.ui.theme.Following @OptIn(ExperimentalFoundationApi::class) @Composable @@ -95,10 +90,13 @@ fun NoteCompose( val baseChannel = note?.channel() if (noteEvent == null) { - BlankNote(modifier.combinedClickable( - onClick = { }, - onLongClick = { popupExpanded = true }, - ), isBoostedNote) + BlankNote( + modifier.combinedClickable( + onClick = { }, + onLongClick = { popupExpanded = true } + ), + isBoostedNote + ) } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { HiddenNote( account.getRelevantReports(noteForReports), @@ -134,45 +132,46 @@ fun NoteCompose( } else { newColor.compositeOver(MaterialTheme.colors.background) } - } else { + } else { parentBackgroundColor ?: MaterialTheme.colors.background - } + } - Column(modifier = modifier - .combinedClickable( - onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}") { - launchSingleTop = true - } - } else { - note - .channel() - ?.let { - navController.navigate("Channel/${it.idHex}") + Column( + modifier = modifier + .combinedClickable( + onClick = { + if (noteEvent !is ChannelMessageEvent) { + navController.navigate("Note/${note.idHex}") { + launchSingleTop = true } - } - }, - onLongClick = { popupExpanded = true } - ) - .background(backgroundColor) + } else { + note + .channel() + ?.let { + navController.navigate("Channel/${it.idHex}") + } + } + }, + onLongClick = { popupExpanded = true } + ) + .background(backgroundColor) ) { - Row( modifier = Modifier .padding( start = if (!isBoostedNote) 12.dp else 0.dp, end = if (!isBoostedNote) 12.dp else 0.dp, - top = 10.dp) + top = 10.dp + ) ) { - if (!isBoostedNote && !isQuotedNote) { Column(Modifier.width(55.dp)) { - // Draws the boosted picture outside the boosted card. - Box(modifier = Modifier - .width(55.dp) - .padding(0.dp)) { - + // Draws the boosted picture outside the boosted card. + Box( + modifier = Modifier + .width(55.dp) + .padding(0.dp) + ) { NoteAuthorPicture(note, navController, account.userProfile(), 55.dp) if (noteEvent is RepostEvent) { @@ -181,8 +180,13 @@ fun NoteCompose( Modifier .width(30.dp) .height(30.dp) - .align(Alignment.BottomEnd)) { - NoteAuthorPicture(it, navController, account.userProfile(), 35.dp, + .align(Alignment.BottomEnd) + ) { + NoteAuthorPicture( + it, + navController, + account.userProfile(), + 35.dp, pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape) ) } @@ -199,7 +203,8 @@ fun NoteCompose( Modifier .width(30.dp) .height(30.dp) - .align(Alignment.BottomEnd)) { + .align(Alignment.BottomEnd) + ) { AsyncImageProxy( model = ResizeImage(channel.profilePicture(), 30.dp), placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)), @@ -244,7 +249,6 @@ fun NoteCompose( NoteUsernameDisplay(note, Modifier.weight(1f)) } - if (noteEvent is RepostEvent) { Text( " ${stringResource(id = R.string.boosted)}", @@ -267,7 +271,7 @@ fun NoteCompose( imageVector = Icons.Default.MoreVert, null, modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) NoteDropDownMenu(baseNote, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel) @@ -400,24 +404,25 @@ fun NoteCompose( } else { val eventContent = accountViewModel.decrypt(note) - val canPreview = note.author == account.userProfile() - || (note.author?.let { account.userProfile().isFollowing(it) } ?: true ) - || !noteForReports.hasAnyReports() + val canPreview = note.author == account.userProfile() || + (note.author?.let { account.userProfile().isFollowing(it) } ?: true) || + !noteForReports.hasAnyReports() if (eventContent != null) { TranslateableRichTextViewer( eventContent, canPreview = canPreview && !makeItShort, Modifier.fillMaxWidth(), - noteEvent.tags, + noteEvent.tags(), backgroundColor, accountViewModel, navController ) } - if (!makeItShort) + if (!makeItShort) { ReactionsRow(note, accountViewModel) + } Divider( modifier = Modifier.padding(top = 10.dp), @@ -425,7 +430,7 @@ fun NoteCompose( ) } - NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) } } } @@ -555,9 +560,10 @@ private fun RelayBadges(baseNote: Note) { Box( Modifier .size(15.dp) - .padding(1.dp)) { + .padding(1.dp) + ) { AsyncImage( - model = "https://${url}/favicon.ico", + model = "https://$url/favicon.ico", placeholder = BitmapPainter(RoboHashCache.get(ctx, url)), fallback = BitmapPainter(RoboHashCache.get(ctx, url)), error = BitmapPainter(RoboHashCache.get(ctx, url)), @@ -577,7 +583,10 @@ private fun RelayBadges(baseNote: Note) { Row( Modifier .fillMaxWidth() - .height(25.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Top) { + .height(25.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Top + ) { IconButton( modifier = Modifier.then(Modifier.size(24.dp)), onClick = { expanded = true } @@ -586,14 +595,13 @@ private fun RelayBadges(baseNote: Note) { imageVector = Icons.Default.ExpandMore, null, modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } } } } - @Composable fun NoteAuthorPicture( note: Note, @@ -607,7 +615,6 @@ fun NoteAuthorPicture( } } - @Composable fun NoteAuthorPicture( baseNote: Note, @@ -626,7 +633,8 @@ fun NoteAuthorPicture( Box( Modifier .width(size) - .height(size)) { + .height(size) + ) { if (author == null) { Image( painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")), @@ -673,8 +681,8 @@ fun UserPicture( Box( Modifier .width(size) - .height(size)) { - + .height(size) + ) { AsyncImageProxy( model = ResizeImage(user.profilePicture(), size), contentDescription = stringResource(id = R.string.profile_image), @@ -686,12 +694,13 @@ fun UserPicture( .clip(shape = CircleShape) .background(MaterialTheme.colors.background) .run { - if (onClick != null && onLongClick != null) - this.combinedClickable(onClick = { onClick(user) }, onLongClick = { onLongClick(user) } ) - else if (onClick != null) - this.clickable(onClick = { onClick(user) } ) - else + if (onClick != null && onLongClick != null) { + this.combinedClickable(onClick = { onClick(user) }, onLongClick = { onLongClick(user) }) + } else if (onClick != null) { + this.clickable(onClick = { onClick(user) }) + } else { this + } } ) @@ -724,7 +733,6 @@ fun UserPicture( ) } } - } } @@ -737,9 +745,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, expanded = popupExpanded, onDismissRequest = onDismiss ) { - if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile() && !accountViewModel.accountLiveData.value?.account?.userProfile() - !!.isFollowing(note.author!!)) { - + if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile() && !accountViewModel.accountLiveData.value?.account?.userProfile()!!.isFollowing(note.author!!)) { DropdownMenuItem(onClick = { accountViewModel.follow( note.author ?: return@DropdownMenuItem @@ -749,16 +755,6 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } Divider() } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) { - Text(stringResource(R.string.copy_text)) - } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkeyNpub() ?: "")); onDismiss() }) { - Text(stringResource(R.string.copy_user_pubkey)) - } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); onDismiss() }) { - Text(stringResource(R.string.copy_note_id)) - } - Divider() DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) { Text(stringResource(R.string.broadcast)) } @@ -782,35 +778,35 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } Divider() DropdownMenuItem(onClick = { - accountViewModel.report(note, ReportEvent.ReportType.SPAM); + accountViewModel.report(note, ReportEvent.ReportType.SPAM) note.author?.let { accountViewModel.hide(it, context) } onDismiss() }) { Text(stringResource(R.string.report_spam_scam)) } DropdownMenuItem(onClick = { - accountViewModel.report(note, ReportEvent.ReportType.PROFANITY); + accountViewModel.report(note, ReportEvent.ReportType.PROFANITY) note.author?.let { accountViewModel.hide(it, context) } onDismiss() }) { Text(stringResource(R.string.report_hateful_speech)) } DropdownMenuItem(onClick = { - accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION); + accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION) note.author?.let { accountViewModel.hide(it, context) } onDismiss() }) { Text(stringResource(R.string.report_impersonation)) } DropdownMenuItem(onClick = { - accountViewModel.report(note, ReportEvent.ReportType.NUDITY); + accountViewModel.report(note, ReportEvent.ReportType.NUDITY) note.author?.let { accountViewModel.hide(it, context) } onDismiss() }) { Text(stringResource(R.string.report_nudity_porn)) } DropdownMenuItem(onClick = { - accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL); + accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL) note.author?.let { accountViewModel.hide(it, context) } onDismiss() }) { @@ -818,4 +814,4 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt new file mode 100644 index 0000000000..bf2d910d2e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -0,0 +1,253 @@ +package com.vitorpamplona.amethyst.ui.note + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FormatQuote +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material.icons.filled.Share +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SelectTextDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.launch + +fun lightenColor(color: Color, amount: Float): Color { + var argb = color.toArgb() + val hslOut = floatArrayOf(0f, 0f, 0f) + ColorUtils.colorToHSL(argb, hslOut) + hslOut[2] += amount + argb = ColorUtils.HSLToColor(hslOut) + return Color(argb) +} + +val externalLinkForNote = { note: Note -> "https://snort.social/e/${note.idNote()}" } + +@Composable +fun VerticalDivider(color: Color) = + Divider( + color = color, + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + ) + +@Composable +fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) { + val context = LocalContext.current + val primaryLight = lightenColor(MaterialTheme.colors.primary, 0.2f) + val cardShape = RoundedCornerShape(5.dp) + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + var showSelectTextDialog by remember { mutableStateOf(false) } + var showDeleteAlertDialog by remember { mutableStateOf(false) } + val isOwnNote = note.author == accountViewModel.userProfile() + val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!) + + val showToast = { stringResource: Int -> + scope.launch { + Toast.makeText( + context, + context.getString(stringResource), + Toast.LENGTH_SHORT + ).show() + } + } + + if (popupExpanded) { + Popup(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), + shape = cardShape, + backgroundColor = MaterialTheme.colors.primary + ) { + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + NoteQuickActionItem( + icon = Icons.Default.ContentCopy, + label = stringResource(R.string.quick_action_copy_text) + ) { + clipboardManager.setText( + AnnotatedString( + accountViewModel.decrypt(note) ?: "" + ) + ) + showToast(R.string.copied_note_text_to_clipboard) + onDismiss() + } + VerticalDivider(primaryLight) + NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) { + clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}" ?: "")) + showToast(R.string.copied_user_id_to_clipboard) + onDismiss() + } + VerticalDivider(primaryLight) + NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) { + clipboardManager.setText(AnnotatedString("@${note.idNote()}")) + showToast(R.string.copied_note_id_to_clipboard) + onDismiss() + } + } + Divider( + color = primaryLight, + modifier = Modifier + .fillMaxWidth() + .width(1.dp) + ) + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + if (isOwnNote) { + NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) { + if (accountViewModel.hideDeleteRequestInfo()) { + accountViewModel.delete(note) + onDismiss() + } else { + showDeleteAlertDialog = true + } + } + } else if (isFollowingUser) { + NoteQuickActionItem(Icons.Default.PersonRemove, stringResource(R.string.quick_action_unfollow)) { + accountViewModel.unfollow(note.author!!) + onDismiss() + } + } else { + NoteQuickActionItem(Icons.Default.PersonAdd, stringResource(R.string.quick_action_follow)) { + accountViewModel.follow(note.author!!) + onDismiss() + } + } + + VerticalDivider(primaryLight) + NoteQuickActionItem( + icon = ImageVector.vectorResource(id = R.drawable.text_select_move_forward_character), + label = stringResource(R.string.quick_action_select) + ) { + showSelectTextDialog = true + onDismiss() + } + VerticalDivider(primaryLight) + NoteQuickActionItem(icon = Icons.Default.Share, label = stringResource(R.string.quick_action_share)) { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForNote(note) + ) + putExtra(Intent.EXTRA_TITLE, context.getString(R.string.quick_action_share_browser_link)) + } + + val shareIntent = Intent.createChooser(sendIntent, context.getString(R.string.quick_action_share)) + ContextCompat.startActivity(context, shareIntent, null) + onDismiss() + } + VerticalDivider(primaryLight) + } + } + } + } + } + + if (showSelectTextDialog) { + accountViewModel.decrypt(note)?.let { + SelectTextDialog(it) { showSelectTextDialog = false } + } + } + + if (showDeleteAlertDialog) { + AlertDialog( + onDismissRequest = { onDismiss() }, + title = { + Text(text = stringResource(R.string.quick_action_request_deletion_alert_title)) + }, + text = { + Text(text = stringResource(R.string.quick_action_request_deletion_alert_body)) + }, + buttons = { + Row( + modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + onClick = { + accountViewModel.setHideDeleteRequestInfo() + accountViewModel.delete(note) + onDismiss() + } + ) { + Text("Don't show again") + } + Button( + onClick = { accountViewModel.delete(note); onDismiss() } + ) { + Text("Delete") + } + } + } + ) + } +} + +@Composable +fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) { + Column( + modifier = Modifier + .size(64.dp) + .clickable { onClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.onPrimary + ) + Text(text = label, fontSize = 12.sp) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt index 5196b42e90..07014d5ae4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt @@ -3,10 +3,9 @@ package com.vitorpamplona.amethyst.ui.note import nostr.postr.toHex fun ByteArray.toShortenHex(): String { - return toHex().toShortenHex() + return toHex().toShortenHex() } fun String.toShortenHex(): String { - return replaceRange(8, length-8, ":") + return replaceRange(8, length - 8, ":") } - diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 70e23fe5d1..b955120a74 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -72,622 +72,624 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.launch import java.math.BigDecimal import java.math.RoundingMode -import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - var wantsToReplyTo by remember { - mutableStateOf(null) - } - - var wantsToQuote by remember { - mutableStateOf(null) - } - - if (wantsToReplyTo != null) - NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account) - - if (wantsToQuote != null) - NewPostView({ wantsToQuote = null }, null, wantsToQuote, account) - - Row( - modifier = Modifier.padding(top = 8.dp).fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) { - wantsToReplyTo = baseNote + var wantsToReplyTo by remember { + mutableStateOf(null) } - BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) { - wantsToQuote = baseNote + var wantsToQuote by remember { + mutableStateOf(null) } - LikeReaction(baseNote, accountViewModel, Modifier.weight(1f)) + if (wantsToReplyTo != null) { + NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account) + } - ZapReaction(baseNote, accountViewModel, Modifier.weight(1f)) + if (wantsToQuote != null) { + NewPostView({ wantsToQuote = null }, null, wantsToQuote, account) + } - ViewCountReaction(baseNote, Modifier.weight(1f)) - } + Row( + modifier = Modifier.padding(top = 8.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) { + wantsToReplyTo = baseNote + } + + BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) { + wantsToQuote = baseNote + } + + LikeReaction(baseNote, accountViewModel, Modifier.weight(1f)) + + ZapReaction(baseNote, accountViewModel, Modifier.weight(1f)) + + ViewCountReaction(baseNote, Modifier.weight(1f)) + } } - @Composable fun ReplyReaction( - baseNote: Note, - accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier, - showCounter: Boolean = true, - onPress: () -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + textModifier: Modifier = Modifier, + showCounter: Boolean = true, + onPress: () -> Unit ) { - val repliesState by baseNote.live().replies.observeAsState() - val replies = repliesState?.note?.replies ?: emptySet() + val repliesState by baseNote.live().replies.observeAsState() + val replies = repliesState?.note?.replies ?: emptySet() - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - val context = LocalContext.current - val scope = rememberCoroutineScope() + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val context = LocalContext.current + val scope = rememberCoroutineScope() - IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), - onClick = { - if (accountViewModel.isWriteable()) - onPress() - else - scope.launch { - Toast.makeText( - context, - context.getString(R.string.login_with_a_private_key_to_be_able_to_reply), - Toast.LENGTH_SHORT - ).show() + IconButton( + modifier = Modifier.then(Modifier.size(20.dp)), + onClick = { + if (accountViewModel.isWriteable()) { + onPress() + } else { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_reply), + Toast.LENGTH_SHORT + ).show() + } + } } + ) { + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Modifier.size(15.dp), + tint = grayTint + ) } - ) { - Icon( - painter = painterResource(R.drawable.ic_comment), - null, - modifier = Modifier.size(15.dp), - tint = grayTint, - ) - } - if (showCounter) - Text( - " ${showCount(replies.size)}", - fontSize = 14.sp, - color = grayTint, - modifier = textModifier - ) + if (showCounter) { + Text( + " ${showCount(replies.size)}", + fontSize = 14.sp, + color = grayTint, + modifier = textModifier + ) + } } @Composable private fun BoostReaction( - baseNote: Note, - accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier, - onQuotePress: () -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + textModifier: Modifier = Modifier, + onQuotePress: () -> Unit ) { - val boostsState by baseNote.live().boosts.observeAsState() - val boostedNote = boostsState?.note + val boostsState by baseNote.live().boosts.observeAsState() + val boostedNote = boostsState?.note - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - val context = LocalContext.current - val scope = rememberCoroutineScope() + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val context = LocalContext.current + val scope = rememberCoroutineScope() - var wantsToBoost by remember { mutableStateOf(false) } + var wantsToBoost by remember { mutableStateOf(false) } - IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), - onClick = { - if (accountViewModel.isWriteable()) { - if (accountViewModel.hasBoosted(baseNote)) { - accountViewModel.deleteBoostsTo(baseNote) + IconButton( + modifier = Modifier.then(Modifier.size(20.dp)), + onClick = { + if (accountViewModel.isWriteable()) { + if (accountViewModel.hasBoosted(baseNote)) { + accountViewModel.deleteBoostsTo(baseNote) + } else { + wantsToBoost = true + } + } else { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_boost_posts), + Toast.LENGTH_SHORT + ).show() + } + } + } + ) { + if (wantsToBoost) { + BoostTypeChoicePopup( + baseNote, + accountViewModel, + onDismiss = { + wantsToBoost = false + }, + onQuote = { + wantsToBoost = false + onQuotePress() + } + ) + } + + if (boostedNote?.isBoostedBy(accountViewModel.userProfile()) == true) { + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) } else { - wantsToBoost = true + Icon( + painter = painterResource(R.drawable.ic_retweet), + null, + modifier = Modifier.size(20.dp), + tint = grayTint + ) } - } else - scope.launch { - Toast.makeText( - context, - context.getString(R.string.login_with_a_private_key_to_be_able_to_boost_posts), - Toast.LENGTH_SHORT - ).show() - } - } - ) { - if (wantsToBoost) { - BoostTypeChoicePopup( - baseNote, - accountViewModel, - onDismiss = { - wantsToBoost = false - }, - onQuote = { - wantsToBoost = false - onQuotePress() - } - ) } - if (boostedNote?.isBoostedBy(accountViewModel.userProfile()) == true) { - Icon( - painter = painterResource(R.drawable.ic_retweeted), - null, - modifier = Modifier.size(20.dp), - tint = Color.Unspecified - ) - } else { - Icon( - painter = painterResource(R.drawable.ic_retweet), - null, - modifier = Modifier.size(20.dp), - tint = grayTint - ) - } - } - - Text( - " ${showCount(boostedNote?.boosts?.size)}", - fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = textModifier - ) + Text( + " ${showCount(boostedNote?.boosts?.size)}", + fontSize = 14.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = textModifier + ) } @Composable fun LikeReaction( - baseNote: Note, - accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier + baseNote: Note, + accountViewModel: AccountViewModel, + textModifier: Modifier = Modifier ) { - val reactionsState by baseNote.live().reactions.observeAsState() - val reactedNote = reactionsState?.note ?: return + val reactionsState by baseNote.live().reactions.observeAsState() + val reactedNote = reactionsState?.note ?: return - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - val context = LocalContext.current - val scope = rememberCoroutineScope() + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val context = LocalContext.current + val scope = rememberCoroutineScope() - IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), - onClick = { - if (accountViewModel.isWriteable()) { - if (accountViewModel.hasReactedTo(baseNote)) { - accountViewModel.deleteReactionTo(baseNote) + IconButton( + modifier = Modifier.then(Modifier.size(20.dp)), + onClick = { + if (accountViewModel.isWriteable()) { + if (accountViewModel.hasReactedTo(baseNote)) { + accountViewModel.deleteReactionTo(baseNote) + } else { + accountViewModel.reactTo(baseNote) + } + } else { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.login_with_a_private_key_to_like_posts), + Toast.LENGTH_SHORT + ).show() + } + } + } + ) { + if (reactedNote?.isReactedBy(accountViewModel.userProfile()) == true) { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = Modifier.size(16.dp), + tint = Color.Unspecified + ) } else { - accountViewModel.reactTo(baseNote) - } - } else - scope.launch { - Toast.makeText( - context, - context.getString(R.string.login_with_a_private_key_to_like_posts), - Toast.LENGTH_SHORT - ).show() + Icon( + painter = painterResource(R.drawable.ic_like), + null, + modifier = Modifier.size(16.dp), + tint = grayTint + ) } } - ) { - if (reactedNote?.isReactedBy(accountViewModel.userProfile()) == true) { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = Modifier.size(16.dp), - tint = Color.Unspecified - ) - } else { - Icon( - painter = painterResource(R.drawable.ic_like), - null, - modifier = Modifier.size(16.dp), - tint = grayTint - ) - } - } - Text( - " ${showCount(reactedNote?.reactions?.size)}", - fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = textModifier - ) + Text( + " ${showCount(reactedNote?.reactions?.size)}", + fontSize = 14.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = textModifier + ) } - @Composable @OptIn(ExperimentalFoundationApi::class) fun ZapReaction( - baseNote: Note, - accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier, + baseNote: Note, + accountViewModel: AccountViewModel, + textModifier: Modifier = Modifier ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - val zapsState by baseNote.live().zaps.observeAsState() - val zappedNote = zapsState?.note + val zapsState by baseNote.live().zaps.observeAsState() + val zappedNote = zapsState?.note - var wantsToZap by remember { mutableStateOf(false) } - var wantsToChangeZapAmount by remember { mutableStateOf(false) } + var wantsToZap by remember { mutableStateOf(false) } + var wantsToChangeZapAmount by remember { mutableStateOf(false) } - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - val context = LocalContext.current - val scope = rememberCoroutineScope() + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val context = LocalContext.current + val scope = rememberCoroutineScope() - Row( - modifier = Modifier - .then(Modifier.size(20.dp)) - .combinedClickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = 24.dp), - onClick = { - if (account.zapAmountChoices.isEmpty()) { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.no_zap_amount_setup_long_press_to_change), - Toast.LENGTH_SHORT - ) - .show() - } - } else if (!accountViewModel.isWriteable()) { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps), - Toast.LENGTH_SHORT - ) - .show() - } - } else if (account.zapAmountChoices.size == 1) { - accountViewModel.zap(baseNote, account.zapAmountChoices.first() * 1000, "", context) { - scope.launch { - Toast - .makeText(context, it, Toast.LENGTH_SHORT) - .show() - } - } - } else if (account.zapAmountChoices.size > 1) { - wantsToZap = true - } - }, - onLongClick = { - wantsToChangeZapAmount = true + Row( + modifier = Modifier + .then(Modifier.size(20.dp)) + .combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp), + onClick = { + if (account.zapAmountChoices.isEmpty()) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.no_zap_amount_setup_long_press_to_change), + Toast.LENGTH_SHORT + ) + .show() + } + } else if (!accountViewModel.isWriteable()) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps), + Toast.LENGTH_SHORT + ) + .show() + } + } else if (account.zapAmountChoices.size == 1) { + accountViewModel.zap(baseNote, account.zapAmountChoices.first() * 1000, "", context) { + scope.launch { + Toast + .makeText(context, it, Toast.LENGTH_SHORT) + .show() + } + } + } else if (account.zapAmountChoices.size > 1) { + wantsToZap = true + } + }, + onLongClick = { + wantsToChangeZapAmount = true + } + ) + ) { + if (wantsToZap) { + ZapAmountChoicePopup( + baseNote, + accountViewModel, + onDismiss = { + wantsToZap = false + }, + onChangeAmount = { + wantsToZap = false + wantsToChangeZapAmount = true + }, + onError = { + scope.launch { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + ) } - ) - ) { - if (wantsToZap) { - ZapAmountChoicePopup( - baseNote, - accountViewModel, - onDismiss = { - wantsToZap = false - }, - onChangeAmount = { - wantsToZap = false - wantsToChangeZapAmount = true - }, - onError = { - scope.launch { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - } + if (wantsToChangeZapAmount) { + UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account) + } + + if (zappedNote?.isZappedBy(account.userProfile()) == true) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange + ) + } else { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp), + tint = grayTint + ) } - ) - } - if (wantsToChangeZapAmount) { - UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account) } - if (zappedNote?.isZappedBy(account.userProfile()) == true) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp), - tint = BitcoinOrange - ) - } else { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp), - tint = grayTint - ) - } - } - - Text( - showAmount(zappedNote?.zappedAmount()), - fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = textModifier - ) + Text( + showAmount(zappedNote?.zappedAmount()), + fontSize = 14.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = textModifier + ) } @Composable private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) { - val uri = LocalUriHandler.current - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val uri = LocalUriHandler.current + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), - onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") } - ) { - Icon( - imageVector = Icons.Outlined.BarChart, - null, - modifier = Modifier.size(19.dp), - tint = grayTint - ) - } + IconButton( + modifier = Modifier.then(Modifier.size(20.dp)), + onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") } + ) { + Icon( + imageVector = Icons.Outlined.BarChart, + null, + modifier = Modifier.size(19.dp), + tint = grayTint + ) + } - Row(modifier = textModifier) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000") - .crossfade(true) - .diskCachePolicy(CachePolicy.DISABLED) - .memoryCachePolicy(CachePolicy.ENABLED) - .build(), - contentDescription = stringResource(R.string.view_count), - modifier = Modifier.height(24.dp), - colorFilter = ColorFilter.tint(grayTint) - ) - } + Row(modifier = textModifier) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000") + .crossfade(true) + .diskCachePolicy(CachePolicy.DISABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .build(), + contentDescription = stringResource(R.string.view_count), + modifier = Modifier.height(24.dp), + colorFilter = ColorFilter.tint(grayTint) + ) + } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) { - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, -50), - onDismissRequest = { onDismiss() } - ) { - FlowRow() { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - accountViewModel.boost(baseNote) - onDismiss() - }, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(stringResource(R.string.boost), color = Color.White, textAlign = TextAlign.Center) - } + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, -50), + onDismissRequest = { onDismiss() } + ) { + FlowRow() { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + accountViewModel.boost(baseNote) + onDismiss() + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(stringResource(R.string.boost), color = Color.White, textAlign = TextAlign.Center) + } - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onQuote, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onQuote, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center) + } + } } - } } @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onChangeAmount: () -> Unit, onError: (text: String) -> Unit) { - val context = LocalContext.current + val context = LocalContext.current - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, -50), - onDismissRequest = { onDismiss() } - ) { - - FlowRow(horizontalArrangement = Arrangement.Center) { - - account.zapAmountChoices.forEach { amountInSats -> - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError) - onDismiss() - }, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text("⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))}", - color = Color.White, - textAlign = TextAlign.Center, - modifier = Modifier.combinedClickable( - onClick = { - accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError) - onDismiss() - }, - onLongClick = { - onChangeAmount() - }, - ) - ) + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, -50), + onDismissRequest = { onDismiss() } + ) { + FlowRow(horizontalArrangement = Arrangement.Center) { + account.zapAmountChoices.forEach { amountInSats -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError) + onDismiss() + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text( + "⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))}", + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.combinedClickable( + onClick = { + accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError) + onDismiss() + }, + onLongClick = { + onChangeAmount() + } + ) + ) + } + } } - } - } - } } -class UpdateZapAmountViewModel: ViewModel() { - private var account: Account? = null +class UpdateZapAmountViewModel : ViewModel() { + private var account: Account? = null - var nextAmount by mutableStateOf(TextFieldValue("")) - var amountSet by mutableStateOf(listOf()) + var nextAmount by mutableStateOf(TextFieldValue("")) + var amountSet by mutableStateOf(listOf()) - fun load(account: Account) { - this.account = account - this.amountSet = account.zapAmountChoices - } + fun load(account: Account) { + this.account = account + this.amountSet = account.zapAmountChoices + } - fun toListOfAmounts(commaSeparatedAmounts: String): List { - return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } - } + fun toListOfAmounts(commaSeparatedAmounts: String): List { + return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } + } - fun addAmount() { - val newValue = nextAmount.text.trim().toLongOrNull() - if (newValue != null) - amountSet = amountSet + newValue + fun addAmount() { + val newValue = nextAmount.text.trim().toLongOrNull() + if (newValue != null) { + amountSet = amountSet + newValue + } - nextAmount = TextFieldValue("") - } + nextAmount = TextFieldValue("") + } - fun removeAmount(amount: Long) { - amountSet = amountSet - amount - } + fun removeAmount(amount: Long) { + amountSet = amountSet - amount + } - fun sendPost() { - account?.changeZapAmounts(amountSet) - nextAmount = TextFieldValue("") - } + fun sendPost() { + account?.changeZapAmounts(amountSet) + nextAmount = TextFieldValue("") + } - fun cancel() { - nextAmount = TextFieldValue("") - } + fun cancel() { + nextAmount = TextFieldValue("") + } - fun hasChanged(): Boolean { - return amountSet != account?.zapAmountChoices - } + fun hasChanged(): Boolean { + return amountSet != account?.zapAmountChoices + } } - @OptIn(ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) @Composable fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) { - val postViewModel: UpdateZapAmountViewModel = viewModel() + val postViewModel: UpdateZapAmountViewModel = viewModel() - val ctx = LocalContext.current.applicationContext + val ctx = LocalContext.current.applicationContext - // initialize focus reference to be able to request focus programmatically - val keyboardController = LocalSoftwareKeyboardController.current + // initialize focus reference to be able to request focus programmatically + val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(account) { - postViewModel.load(account) - } - - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false, - ) - ) { - Surface() { - Column(modifier = Modifier.padding(10.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = { - postViewModel.cancel() - onClose() - }) - - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged() - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - postViewModel.amountSet.forEach { amountInSats -> - Button( - modifier = Modifier.padding(horizontal = 3.dp), - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ), - onClick = { - postViewModel.removeAmount(amountInSats) - } - ) { - Text("⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))} ✖", color = Color.White, textAlign = TextAlign.Center) - } - } - } - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, - value = postViewModel.nextAmount, - onValueChange = { - postViewModel.nextAmount = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number - ), - placeholder = { - Text( - text = "100, 1000, 5000", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - singleLine = true, - modifier = Modifier - .padding(end = 10.dp) - .weight(1f) - ) - - Button( - onClick = { postViewModel.addAmount() }, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.add), color = Color.White) - } - } - } + LaunchedEffect(account) { + postViewModel.load(account) + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + Surface() { + Column(modifier = Modifier.padding(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + postViewModel.cancel() + onClose() + }) + + SaveButton( + onPost = { + postViewModel.sendPost() + onClose() + }, + isActive = postViewModel.hasChanged() + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + postViewModel.amountSet.forEach { amountInSats -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ), + onClick = { + postViewModel.removeAmount(amountInSats) + } + ) { + Text("⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))} ✖", color = Color.White, textAlign = TextAlign.Center) + } + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, + value = postViewModel.nextAmount, + onValueChange = { + postViewModel.nextAmount = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier + .padding(end = 10.dp) + .weight(1f) + ) + + Button( + onClick = { postViewModel.addAmount() }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.add), color = Color.White) + } + } + } + } } - } } fun showCount(count: Int?): String { - if (count == null) return "" - if (count == 0) return "" + if (count == null) return "" + if (count == 0) return "" - return when { - count >= 1000000000 -> "${Math.round(count / 1000000000f)}G" - count >= 1000000 -> "${Math.round(count / 1000000f)}M" - count >= 1000 -> "${Math.round(count / 1000f)}k" - else -> "$count" - } + return when { + count >= 1000000000 -> "${Math.round(count / 1000000000f)}G" + count >= 1000000 -> "${Math.round(count / 1000000f)}M" + count >= 1000 -> "${Math.round(count / 1000f)}k" + else -> "$count" + } } val OneGiga = BigDecimal(1000000000) @@ -695,13 +697,13 @@ val OneMega = BigDecimal(1000000) val OneKilo = BigDecimal(1000) fun showAmount(amount: BigDecimal?): String { - if (amount == null) return "" - if (amount.abs() < BigDecimal(0.01)) return "" + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" - return when { - amount >= OneGiga -> "%.1fG".format(amount.div(OneGiga).setScale(1, RoundingMode.HALF_UP)) - amount >= OneMega -> "%.1fM".format(amount.div(OneMega).setScale(1, RoundingMode.HALF_UP)) - amount >= OneKilo -> "%.1fk".format(amount.div(OneKilo).setScale(1, RoundingMode.HALF_UP)) - else -> "%.0f".format(amount) - } -} \ No newline at end of file + return when { + amount >= OneGiga -> "%.1fG".format(amount.div(OneGiga).setScale(1, RoundingMode.HALF_UP)) + amount >= OneMega -> "%.1fM".format(amount.div(OneMega).setScale(1, RoundingMode.HALF_UP)) + amount >= OneKilo -> "%.1fk".format(amount.div(OneKilo).setScale(1, RoundingMode.HALF_UP)) + else -> "%.0f".format(amount) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 05bee3dcf5..9625a2c49d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -48,12 +48,13 @@ fun RelayCompose( modifier = Modifier .padding(start = 12.dp, end = 12.dp, top = 10.dp) ) { + // UserPicture(user, navController, account.userProfile(), 55.dp) - //UserPicture(user, navController, account.userProfile(), 55.dp) - - Column(modifier = Modifier - .padding(start = 10.dp) - .weight(1f)) { + Column( + modifier = Modifier + .padding(start = 10.dp) + .weight(1f) + ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( relay.url.trim().removePrefix("wss://"), @@ -127,4 +128,4 @@ fun RemoveRelayButton(onClick: () -> Unit) { fun formattedDateTime(timestamp: Long): String { return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) .format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a")) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 5d64ec1ee2..f4735e04d2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -7,179 +7,179 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @Composable fun ReplyInformation(replyTo: List?, mentions: List?, account: Account, navController: NavController) { - ReplyInformation(replyTo, mentions, account) { - navController.navigate("User/${it.pubkeyHex}") - } + ReplyInformation(replyTo, mentions, account) { + navController.navigate("User/${it.pubkeyHex}") + } } @Composable fun ReplyInformation(replyTo: List?, dupMentions: List?, account: Account, prefix: String = "", onUserTagClick: (User) -> Unit) { - val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowing(it) } - var expanded by remember { mutableStateOf((mentions?.size ?: 0) <= 2) } + val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowing(it) } + var expanded by remember { mutableStateOf((mentions?.size ?: 0) <= 2) } - FlowRow() { - if (mentions != null && mentions.isNotEmpty()) { - if (replyTo != null && replyTo.isNotEmpty()) { - val repliesToDisplay = if (expanded) mentions else mentions.take(2) - - Text( - stringResource(R.string.replying_to), - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - - repliesToDisplay.forEachIndexed { idx, user -> - val innerUserState by user.live().metadata.observeAsState() - val innerUser = innerUserState?.user - - innerUser?.let { myUser -> - ClickableText( - AnnotatedString("${prefix}@${myUser.toBestDisplayName()}"), - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), - onClick = { onUserTagClick(myUser) } - ) - - if (expanded) { - if (idx < repliesToDisplay.size - 2) { - Text( - ", ", - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } else if (idx < repliesToDisplay.size - 1) { - Text( - " ${stringResource(R.string.and)} ", - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } - } else { - if (idx < repliesToDisplay.size - 1) { - Text( - ", ", - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } else if (idx < repliesToDisplay.size) { - Text( - " ${stringResource(R.string.and)} ", - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - - ClickableText( - AnnotatedString("${mentions.size-2}"), - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), - onClick = { expanded = true } - ) + FlowRow() { + if (mentions != null && mentions.isNotEmpty()) { + if (replyTo != null && replyTo.isNotEmpty()) { + val repliesToDisplay = if (expanded) mentions else mentions.take(2) Text( - " ${stringResource(R.string.others)}", - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + stringResource(R.string.replying_to), + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) - } + + repliesToDisplay.forEachIndexed { idx, user -> + val innerUserState by user.live().metadata.observeAsState() + val innerUser = innerUserState?.user + + innerUser?.let { myUser -> + ClickableText( + AnnotatedString("$prefix@${myUser.toBestDisplayName()}"), + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), + onClick = { onUserTagClick(myUser) } + ) + + if (expanded) { + if (idx < repliesToDisplay.size - 2) { + Text( + ", ", + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } else if (idx < repliesToDisplay.size - 1) { + Text( + " ${stringResource(R.string.and)} ", + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } else { + if (idx < repliesToDisplay.size - 1) { + Text( + ", ", + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } else if (idx < repliesToDisplay.size) { + Text( + " ${stringResource(R.string.and)} ", + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + ClickableText( + AnnotatedString("${mentions.size - 2}"), + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), + onClick = { expanded = true } + ) + + Text( + " ${stringResource(R.string.others)}", + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + } + } } - } } - } } - } } - @Composable fun ReplyInformationChannel(replyTo: List?, mentions: List?, channel: Channel, navController: NavController) { - ReplyInformationChannel(replyTo, mentions, channel, - onUserTagClick = { - navController.navigate("User/${it.pubkeyHex}") - }, - onChannelTagClick = { - navController.navigate("Channel/${it.idHex}") - } - ) + ReplyInformationChannel( + replyTo, + mentions, + channel, + onUserTagClick = { + navController.navigate("User/${it.pubkeyHex}") + }, + onChannelTagClick = { + navController.navigate("Channel/${it.idHex}") + } + ) } - @Composable -fun ReplyInformationChannel(replyTo: List?, - mentions: List?, - baseChannel: Channel, - prefix: String = "", - onUserTagClick: (User) -> Unit, - onChannelTagClick: (Channel) -> Unit +fun ReplyInformationChannel( + replyTo: List?, + mentions: List?, + baseChannel: Channel, + prefix: String = "", + onUserTagClick: (User) -> Unit, + onChannelTagClick: (Channel) -> Unit ) { - val channelState by baseChannel.live.observeAsState() - val channel = channelState?.channel ?: return + val channelState by baseChannel.live.observeAsState() + val channel = channelState?.channel ?: return - FlowRow() { - Text( - stringResource(R.string.in_channel), - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - - ClickableText( - AnnotatedString("${channel.info.name} "), - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), - onClick = { onChannelTagClick(channel) } - ) - - if (mentions != null && mentions.isNotEmpty()) { - if (replyTo != null && replyTo.isNotEmpty()) { + FlowRow() { Text( - stringResource(id = R.string.replying_to), - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + stringResource(R.string.in_channel), + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) - val mentionSet = mentions.toSet() + ClickableText( + AnnotatedString("${channel.info.name} "), + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), + onClick = { onChannelTagClick(channel) } + ) - mentionSet.forEachIndexed { idx, user -> - val innerUserState by user.live().metadata.observeAsState() - val innerUser = innerUserState?.user + if (mentions != null && mentions.isNotEmpty()) { + if (replyTo != null && replyTo.isNotEmpty()) { + Text( + stringResource(id = R.string.replying_to), + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) - innerUser?.let { myUser -> - ClickableText( - AnnotatedString("${prefix}@${myUser.toBestDisplayName()}"), - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), - onClick = { onUserTagClick(myUser) } - ) + val mentionSet = mentions.toSet() - if (idx < mentionSet.size - 2) { - Text( - ", ", - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } else if (idx < mentionSet.size - 1) { - Text( - " ${stringResource(id = R.string.add)} ", - fontSize = 13.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) + mentionSet.forEachIndexed { idx, user -> + val innerUserState by user.live().metadata.observeAsState() + val innerUser = innerUserState?.user + + innerUser?.let { myUser -> + ClickableText( + AnnotatedString("$prefix@${myUser.toBestDisplayName()}"), + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), + onClick = { onUserTagClick(myUser) } + ) + + if (idx < mentionSet.size - 2) { + Text( + ", ", + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } else if (idx < mentionSet.size - 1) { + Text( + " ${stringResource(id = R.string.add)} ", + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + } } - } } - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 17e23b7ed6..7ba20cf641 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -4,54 +4,54 @@ import android.content.Context import android.text.format.DateUtils import com.vitorpamplona.amethyst.R -fun timeAgo(mills: Long?, context : Context): String { - if (mills == null) return " " - if (mills == 0L) return " • ${context.getString(R.string.never)}" +fun timeAgo(mills: Long?, context: Context): String { + if (mills == null) return " " + if (mills == 0L) return " • ${context.getString(R.string.never)}" - var humanReadable = DateUtils.getRelativeTimeSpanString( - mills * 1000, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_ALL - ).toString() - if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { - humanReadable = context.getString(R.string.now); - } + var humanReadable = DateUtils.getRelativeTimeSpanString( + mills * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_ALL + ).toString() + if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { + humanReadable = context.getString(R.string.now) + } - return " • " + humanReadable - .replace(" hr. ago", context.getString(R.string.h)) - .replace(" min. ago", context.getString(R.string.m)) - .replace(" days ago", context.getString(R.string.d)) + return " • " + humanReadable + .replace(" hr. ago", context.getString(R.string.h)) + .replace(" min. ago", context.getString(R.string.m)) + .replace(" days ago", context.getString(R.string.d)) } fun timeAgoShort(mills: Long?, context: Context): String { - if (mills == null) return " " + if (mills == null) return " " - var humanReadable = DateUtils.getRelativeTimeSpanString( - mills * 1000, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_ALL - ).toString() - if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { - humanReadable = context.getString(R.string.now); - } + var humanReadable = DateUtils.getRelativeTimeSpanString( + mills * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_ALL + ).toString() + if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { + humanReadable = context.getString(R.string.now) + } - return humanReadable + return humanReadable } fun timeAgoLong(mills: Long?, context: Context): String { - if (mills == null) return " " + if (mills == null) return " " - var humanReadable = DateUtils.getRelativeTimeSpanString( - mills * 1000, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_SHOW_TIME - ).toString() - if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { - humanReadable = context.getString(R.string.now); - } + var humanReadable = DateUtils.getRelativeTimeSpanString( + mills * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_SHOW_TIME + ).toString() + if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { + humanReadable = context.getString(R.string.now) + } - return humanReadable -} \ No newline at end of file + return humanReadable +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index dc9854ddc1..24bb69ee1a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -36,7 +36,8 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle val ctx = LocalContext.current.applicationContext val coroutineScope = rememberCoroutineScope() - Column(modifier = + Column( + modifier = Modifier.clickable( onClick = { navController.navigate("User/${baseUser.pubkeyHex}") } ) @@ -46,10 +47,10 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle .padding( start = 12.dp, end = 12.dp, - top = 10.dp), + top = 10.dp + ), verticalAlignment = Alignment.CenterVertically ) { - UserPicture(baseUser, navController, account.userProfile(), 55.dp) Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { @@ -86,4 +87,4 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle thickness = 0.25.dp ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index e981fb80fb..ddb394276d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -11,59 +11,58 @@ import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User - @Composable fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) { - val noteState by baseNote.live().metadata.observeAsState() - val note = noteState?.note ?: return + val noteState by baseNote.live().metadata.observeAsState() + val note = noteState?.note ?: return - val author = note.author + val author = note.author - if (author != null) { - UsernameDisplay(author, weight) - } + if (author != null) { + UsernameDisplay(author, weight) + } } @Composable fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) { - val userState by baseUser.live().metadata.observeAsState() - val user = userState?.user ?: return + val userState by baseUser.live().metadata.observeAsState() + val user = userState?.user ?: return - if (user.bestUsername() != null && user.bestDisplayName() != null) { - Text( - user.bestDisplayName() ?: "", - fontWeight = FontWeight.Bold, - ) - Text( - "@${(user.bestUsername() ?: "")}", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = weight - ) - } else if (user.bestDisplayName() != null) { - Text( - user.bestDisplayName() ?: "", - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = weight - ) - } else if (user.bestUsername() != null) { - Text( - "@${(user.bestUsername() ?: "")}", - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = weight - ) - } else { - Text( - user.pubkeyDisplayHex(), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = weight - ) - } -} \ No newline at end of file + if (user.bestUsername() != null && user.bestDisplayName() != null) { + Text( + user.bestDisplayName() ?: "", + fontWeight = FontWeight.Bold + ) + Text( + "@${(user.bestUsername() ?: "")}", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = weight + ) + } else if (user.bestDisplayName() != null) { + Text( + user.bestDisplayName() ?: "", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = weight + ) + } else if (user.bestUsername() != null) { + Text( + "@${(user.bestUsername() ?: "")}", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = weight + ) + } else { + Text( + user.pubkeyDisplayHex(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = weight + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index fc67735908..f4497bdc57 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.LnZapEvent @@ -55,7 +54,8 @@ fun ZapNoteCompose(baseNote: Pair, accountViewModel: AccountViewMode if (baseAuthor == null) { BlankNote() } else { - Column(modifier = + Column( + modifier = Modifier.clickable( onClick = { navController.navigate("User/${baseAuthor.pubkeyHex}") } ), @@ -70,7 +70,6 @@ fun ZapNoteCompose(baseNote: Pair, accountViewModel: AccountViewMode ), verticalAlignment = Alignment.CenterVertically ) { - UserPicture(baseAuthor, navController, account.userProfile(), 55.dp) Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { @@ -100,7 +99,7 @@ fun ZapNoteCompose(baseNote: Pair, accountViewModel: AccountViewMode "${showAmount(amount)} ${stringResource(R.string.sats)}", color = BitcoinOrange, fontSize = 20.sp, - fontWeight = FontWeight.W500, + fontWeight = FontWeight.W500 ) } @@ -123,4 +122,4 @@ fun ZapNoteCompose(baseNote: Pair, accountViewModel: AccountViewMode ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt index e1ecdcf779..6785d35992 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt @@ -69,31 +69,34 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner Column( modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}"){ - launchSingleTop = true - } - } else { - note.channel()?.let { - navController.navigate("Channel/${it.idHex}") - } - } + if (noteEvent !is ChannelMessageEvent) { + navController.navigate("Note/${note.idHex}") { + launchSingleTop = true + } + } else { + note.channel()?.let { + navController.navigate("Channel/${it.idHex}") + } + } }, onLongClick = { popupExpanded = true } ) ) { - Row(modifier = Modifier - .padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp) + Row( + modifier = Modifier + .padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp + ) ) { - // Draws the like picture outside the boosted card. if (!isInnerNote) { - Box(modifier = Modifier - .width(55.dp) - .padding(0.dp)) { + Box( + modifier = Modifier + .width(55.dp) + .padding(0.dp) + ) { Icon( imageVector = Icons.Default.Bolt, contentDescription = stringResource(id = R.string.zaps), @@ -132,4 +135,4 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt index 1b30d7a5b6..ac9669c6f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt @@ -26,128 +26,127 @@ import com.google.zxing.qrcode.encoder.ByteMatrix import com.google.zxing.qrcode.encoder.Encoder import com.google.zxing.qrcode.encoder.QRCode - const val QR_MARGIN_PX = 100f @Composable fun QrCodeDrawer(contents: String, modifier: Modifier = Modifier) { - val qrCode = remember(contents) { - createQrCode(contents = contents) - } - - val foregroundColor = MaterialTheme.colors.onSurface - - Box( - modifier = modifier - .defaultMinSize(48.dp, 48.dp) - .aspectRatio(1f) - .background(MaterialTheme.colors.background) - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - // Calculate the height and width of each column/row - val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height - val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width - - // Draw all of the finder patterns required by the QR spec. Calculate the ratio - // of the number of rows/columns to the width and height - drawQrCodeFinders( - sideLength = size.width, - finderPatternSize = Size( - width = columnWidth * FINDER_PATTERN_ROW_COUNT, - height = rowHeight * FINDER_PATTERN_ROW_COUNT - ), - color = foregroundColor - ) - - // Draw data bits (encoded data part) - drawAllQrCodeDataBits( - bytes = qrCode.matrix, - size = Size( - width = columnWidth, - height = rowHeight - ), - color = foregroundColor - ) + val qrCode = remember(contents) { + createQrCode(contents = contents) + } + + val foregroundColor = MaterialTheme.colors.onSurface + + Box( + modifier = modifier + .defaultMinSize(48.dp, 48.dp) + .aspectRatio(1f) + .background(MaterialTheme.colors.background) + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + // Calculate the height and width of each column/row + val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height + val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width + + // Draw all of the finder patterns required by the QR spec. Calculate the ratio + // of the number of rows/columns to the width and height + drawQrCodeFinders( + sideLength = size.width, + finderPatternSize = Size( + width = columnWidth * FINDER_PATTERN_ROW_COUNT, + height = rowHeight * FINDER_PATTERN_ROW_COUNT + ), + color = foregroundColor + ) + + // Draw data bits (encoded data part) + drawAllQrCodeDataBits( + bytes = qrCode.matrix, + size = Size( + width = columnWidth, + height = rowHeight + ), + color = foregroundColor + ) + } } - } } private typealias Coordinate = Pair private fun createQrCode(contents: String): QRCode { - require(contents.isNotEmpty()) + require(contents.isNotEmpty()) - return Encoder.encode( - contents, - ErrorCorrectionLevel.Q, - mapOf( - EncodeHintType.CHARACTER_SET to "UTF-8", - EncodeHintType.MARGIN to QR_MARGIN_PX, - EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q + return Encoder.encode( + contents, + ErrorCorrectionLevel.Q, + mapOf( + EncodeHintType.CHARACTER_SET to "UTF-8", + EncodeHintType.MARGIN to QR_MARGIN_PX, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q + ) ) - ) } fun newPath(withPath: Path.() -> Unit) = Path().apply { - fillType = PathFillType.EvenOdd - withPath(this) + fillType = PathFillType.EvenOdd + withPath(this) } fun DrawScope.drawAllQrCodeDataBits( - bytes: ByteMatrix, - size: Size, - color: Color, + bytes: ByteMatrix, + size: Size, + color: Color ) { - setOf( - // data bits between top left finder pattern and top right finder pattern. - Pair( - first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0), - second = Coordinate( - first = (bytes.width - FINDER_PATTERN_ROW_COUNT), - second = FINDER_PATTERN_ROW_COUNT - ) - ), - // data bits below top left finder pattern and above bottom left finder pattern. - Pair( - first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT), - second = Coordinate( - first = bytes.width, - second = bytes.height - FINDER_PATTERN_ROW_COUNT - ) - ), - // data bits to the right of the bottom left finder pattern. - Pair( - first = Coordinate( - first = FINDER_PATTERN_ROW_COUNT, - second = (bytes.height - FINDER_PATTERN_ROW_COUNT) - ), - second = Coordinate( - first = bytes.width, - second = bytes.height - ) - ) - ).forEach { section -> - for (y in section.first.second until section.second.second) { - for (x in section.first.first until section.second.first) { - if (bytes[x, y] == 1.toByte()) { - drawPath( - color = color, - path = newPath { - addRect( - rect = Rect( - offset = Offset( - x = QR_MARGIN_PX + x * size.width, - y = QR_MARGIN_PX + y * size.height - ), - size = size - ) - ) + setOf( + // data bits between top left finder pattern and top right finder pattern. + Pair( + first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0), + second = Coordinate( + first = (bytes.width - FINDER_PATTERN_ROW_COUNT), + second = FINDER_PATTERN_ROW_COUNT + ) + ), + // data bits below top left finder pattern and above bottom left finder pattern. + Pair( + first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT), + second = Coordinate( + first = bytes.width, + second = bytes.height - FINDER_PATTERN_ROW_COUNT + ) + ), + // data bits to the right of the bottom left finder pattern. + Pair( + first = Coordinate( + first = FINDER_PATTERN_ROW_COUNT, + second = (bytes.height - FINDER_PATTERN_ROW_COUNT) + ), + second = Coordinate( + first = bytes.width, + second = bytes.height + ) + ) + ).forEach { section -> + for (y in section.first.second until section.second.second) { + for (x in section.first.first until section.second.first) { + if (bytes[x, y] == 1.toByte()) { + drawPath( + color = color, + path = newPath { + addRect( + rect = Rect( + offset = Offset( + x = QR_MARGIN_PX + x * size.width, + y = QR_MARGIN_PX + y * size.height + ), + size = size + ) + ) + } + ) + } } - ) } - } } - } } const val FINDER_PATTERN_ROW_COUNT = 7 @@ -166,80 +165,79 @@ private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f * @param finderPatternSize [Size] of each finder patten, based on the QR code spec */ internal fun DrawScope.drawQrCodeFinders( - sideLength: Float, - finderPatternSize: Size, - color: Color + sideLength: Float, + finderPatternSize: Size, + color: Color ) { - - setOf( - // Draw top left finder pattern. - Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX), - // Draw top right finder pattern. - Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX), - // Draw bottom finder pattern. - Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)) - ).forEach { offset -> - drawQrCodeFinder( - topLeft = offset, - finderPatternSize = finderPatternSize, - cornerRadius = CornerRadius.Zero, - color = color - ) - } + setOf( + // Draw top left finder pattern. + Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX), + // Draw top right finder pattern. + Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX), + // Draw bottom finder pattern. + Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)) + ).forEach { offset -> + drawQrCodeFinder( + topLeft = offset, + finderPatternSize = finderPatternSize, + cornerRadius = CornerRadius.Zero, + color = color + ) + } } /** * This func is responsible for drawing a single finder pattern, for a QR code */ private fun DrawScope.drawQrCodeFinder( - topLeft: Offset, - finderPatternSize: Size, - cornerRadius: CornerRadius, - color: Color + topLeft: Offset, + finderPatternSize: Size, + cornerRadius: CornerRadius, + color: Color ) { - drawPath( - color = color, - path = newPath { - // Draw the outer rectangle for the finder pattern. - addRoundRect( - roundRect = RoundRect( - rect = Rect( - offset = topLeft, - size = finderPatternSize - ), - cornerRadius = cornerRadius - ) - ) + drawPath( + color = color, + path = newPath { + // Draw the outer rectangle for the finder pattern. + addRoundRect( + roundRect = RoundRect( + rect = Rect( + offset = topLeft, + size = finderPatternSize + ), + cornerRadius = cornerRadius + ) + ) - // Draw background for the finder pattern interior (this keeps the arc ratio consistent). - val innerBackgroundOffset = Offset( - x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO, - y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO - ) - addRoundRect( - roundRect = RoundRect( - rect = Rect( - offset = topLeft + innerBackgroundOffset, - size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO - ), - cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS - ) - ) + // Draw background for the finder pattern interior (this keeps the arc ratio consistent). + val innerBackgroundOffset = Offset( + x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO, + y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO + ) + addRoundRect( + roundRect = RoundRect( + rect = Rect( + offset = topLeft + innerBackgroundOffset, + size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO + ), + cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS + ) + ) - // Draw the inner rectangle for the finder pattern. - val innerRectOffset = Offset( - x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO, - y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO - ) - addRoundRect( - roundRect = RoundRect( - rect = Rect( - offset = topLeft + innerRectOffset, - size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO - ), - cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS - ) - ) - } - ) + // Draw the inner rectangle for the finder pattern. + val innerRectOffset = Offset( + x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO, + y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO + ) + addRoundRect( + roundRect = RoundRect( + rect = Rect( + offset = topLeft + innerRectOffset, + size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO + ), + cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS + ) + ) + } + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt index 71ec22399b..1d3aa37d4a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt @@ -21,7 +21,7 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage -import com.vitorpamplona.amethyst.service.Nip19 +import com.vitorpamplona.amethyst.service.nip19.Nip19 import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -31,7 +31,7 @@ fun QrCodeScanner(onScan: (String) -> Unit) { val lifecycleOwner = LocalLifecycleOwner.current val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } - val cameraExecutor= Executors.newSingleThreadExecutor() + val cameraExecutor = Executors.newSingleThreadExecutor() var hasCameraPermission by remember { mutableStateOf( @@ -52,7 +52,7 @@ fun QrCodeScanner(onScan: (String) -> Unit) { val analyzer = QRCodeAnalyzer { result -> result?.let { try { - val nip19 = Nip19().uriToRoute(it) + val nip19 = Nip19.uriToRoute(it) val startingPage = when (nip19?.type) { Nip19.Type.USER -> "User/${nip19.hex}" Nip19.Type.NOTE -> "Note/${nip19.hex}" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index 8e49766197..eb61f2ede6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -34,8 +34,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import coil.compose.AsyncImage -import coil.compose.rememberAsyncImagePainter import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.model.User @@ -46,140 +44,131 @@ import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner @Composable fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { - var presenting by remember { mutableStateOf(true) } + var presenting by remember { mutableStateOf(true) } - val ctx = LocalContext.current.applicationContext + val ctx = LocalContext.current.applicationContext - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .background(MaterialTheme.colors.background) - .fillMaxSize(), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = onClose) - } - - Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp), - verticalArrangement = Arrangement.SpaceBetween - ) { - if (presenting) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp) - ) { - - } - - Column(modifier = Modifier.fillMaxWidth()) { - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - AsyncImageProxy( - model = ResizeImage(user.profilePicture(), 100.dp), - placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), - fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), - error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), - contentDescription = stringResource(R.string.profile_image), - modifier = Modifier - .width(100.dp) - .height(100.dp) - .clip(shape = CircleShape) - .border(3.dp, MaterialTheme.colors.background, CircleShape) + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier .background(MaterialTheme.colors.background) - ) - } - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Text( - user.bestDisplayName() ?: "", - modifier = Modifier.padding(top = 7.dp), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - } - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Text(" @${user.bestUsername()}", color = Color.LightGray) - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 35.dp, vertical = 10.dp) - ) { - QrCodeDrawer("nostr:${user.pubkeyNpub()}") - } - - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp) + .fillMaxSize() ) { + Row( + modifier = Modifier.fillMaxWidth().padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = onClose) + } - Button( - onClick = { presenting = false }, - shape = RoundedCornerShape(35.dp), - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.scan_qr)) - } + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + if (presenting) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp) + ) { + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + AsyncImageProxy( + model = ResizeImage(user.profilePicture(), 100.dp), + placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), + fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), + error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), + contentDescription = stringResource(R.string.profile_image), + modifier = Modifier + .width(100.dp) + .height(100.dp) + .clip(shape = CircleShape) + .border(3.dp, MaterialTheme.colors.background, CircleShape) + .background(MaterialTheme.colors.background) + ) + } + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Text( + user.bestDisplayName() ?: "", + modifier = Modifier.padding(top = 7.dp), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + } + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Text(" @${user.bestUsername()}", color = Color.LightGray) + } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 35.dp, vertical = 10.dp) + ) { + QrCodeDrawer("nostr:${user.pubkeyNpub()}") + } + } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp) + ) { + Button( + onClick = { presenting = false }, + shape = RoundedCornerShape(35.dp), + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.scan_qr)) + } + } + } else { + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.point_to_the_qr_code), + modifier = Modifier.padding(top = 7.dp), + fontWeight = FontWeight.Bold, + fontSize = 25.sp + ) + } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(30.dp) + ) { + QrCodeScanner(onScan) + } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp) + ) { + Button( + onClick = { presenting = true }, + shape = RoundedCornerShape(35.dp), + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.show_qr)) + } + } + } + } } - - } else { - - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Text( - stringResource(R.string.point_to_the_qr_code), - modifier = Modifier.padding(top = 7.dp), - fontWeight = FontWeight.Bold, - fontSize = 25.sp - ) - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(30.dp) - ) { - QrCodeScanner(onScan) - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp) - ) { - - Button( - onClick = { presenting = true }, - shape = RoundedCornerShape(35.dp), - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.show_qr)) - } - } - - } - - } - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 66d458e247..d82d6a353c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -11,22 +11,21 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen @Composable fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) { - val accountState by accountStateViewModel.accountContent.collectAsState() + val accountState by accountStateViewModel.accountContent.collectAsState() - Column() { - Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is AccountState.LoggedOff -> { - LoginPage(accountStateViewModel) + Column() { + Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is AccountState.LoggedOff -> { + LoginPage(accountStateViewModel) + } + is AccountState.LoggedIn -> { + MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage) + } + is AccountState.LoggedInViewOnly -> { + MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage) + } + } } - is AccountState.LoggedIn -> { - MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage) - } - is AccountState.LoggedInViewOnly -> { - MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage) - } - } } - } } - diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index df6488b034..25f3ece422 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import com.vitorpamplona.amethyst.model.Account sealed class AccountState { - object LoggedOff: AccountState() - class LoggedInViewOnly(val account: Account): AccountState() - class LoggedIn(val account: Account): AccountState() + object LoggedOff : AccountState() + class LoggedInViewOnly(val account: Account) : AccountState() + class LoggedIn(val account: Account) : AccountState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 2f185daac4..e57a4ae1bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -5,7 +5,6 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.model.Account import fr.acinq.secp256k1.Hex -import java.util.regex.Pattern import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -16,89 +15,91 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import nostr.postr.Persona import nostr.postr.bechToBytes +import java.util.regex.Pattern -class AccountStateViewModel(private val localPreferences: LocalPreferences): ViewModel() { - private val _accountContent = MutableStateFlow(AccountState.LoggedOff) - val accountContent = _accountContent.asStateFlow() +class AccountStateViewModel(private val localPreferences: LocalPreferences) : ViewModel() { + private val _accountContent = MutableStateFlow(AccountState.LoggedOff) + val accountContent = _accountContent.asStateFlow() - init { - // pulls account from storage. + init { + // pulls account from storage. - // Keeps it in the the UI thread to void blinking the login page. - //viewModelScope.launch(Dispatchers.IO) { - localPreferences.loadFromEncryptedStorage()?.let { - login(it) - } - //} - } - - fun login(key: String) { - val pattern = Pattern.compile(".+@.+\\.[a-z]+") - - val account = - if (key.startsWith("nsec")) { - Account(Persona(privKey = key.bechToBytes())) - } else if (key.startsWith("npub")) { - Account(Persona(pubKey = key.bechToBytes())) - } else if (pattern.matcher(key).matches()) { - // Evaluate NIP-5 - Account(Persona()) - } else { - Account(Persona(Hex.decode(key))) - } - - localPreferences.saveToEncryptedStorage(account) - - login(account) - } - - fun newKey() { - val account = Account(Persona()) - localPreferences.saveToEncryptedStorage(account) - login(account) - } - - fun login(account: Account) { - if (account.loggedIn.privKey != null) - _accountContent.update { AccountState.LoggedIn ( account ) } - else - _accountContent.update { AccountState.LoggedInViewOnly ( account ) } - - val scope = CoroutineScope(Job() + Dispatchers.IO) - scope.launch { - ServiceManager.start(account) - } - - GlobalScope.launch(Dispatchers.Main) { - account.saveable.observeForever(saveListener) - } - } - - private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { - GlobalScope.launch(Dispatchers.IO) { - localPreferences.saveToEncryptedStorage(it.account) - } - } - - fun logOff() { - val state = accountContent.value - - when (state) { - is AccountState.LoggedIn -> { - GlobalScope.launch(Dispatchers.Main) { - state.account.saveable.removeObserver(saveListener) + // Keeps it in the the UI thread to void blinking the login page. + // viewModelScope.launch(Dispatchers.IO) { + localPreferences.loadFromEncryptedStorage()?.let { + login(it) } - } - is AccountState.LoggedInViewOnly -> { - GlobalScope.launch(Dispatchers.Main) { - state.account.saveable.removeObserver(saveListener) - } - } - else -> {} + // } } - _accountContent.update { AccountState.LoggedOff } + fun login(key: String) { + val pattern = Pattern.compile(".+@.+\\.[a-z]+") - localPreferences.clearEncryptedStorage() - } -} \ No newline at end of file + val account = + if (key.startsWith("nsec")) { + Account(Persona(privKey = key.bechToBytes())) + } else if (key.startsWith("npub")) { + Account(Persona(pubKey = key.bechToBytes())) + } else if (pattern.matcher(key).matches()) { + // Evaluate NIP-5 + Account(Persona()) + } else { + Account(Persona(Hex.decode(key))) + } + + localPreferences.saveToEncryptedStorage(account) + + login(account) + } + + fun newKey() { + val account = Account(Persona()) + localPreferences.saveToEncryptedStorage(account) + login(account) + } + + fun login(account: Account) { + if (account.loggedIn.privKey != null) { + _accountContent.update { AccountState.LoggedIn(account) } + } else { + _accountContent.update { AccountState.LoggedInViewOnly(account) } + } + + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + ServiceManager.start(account) + } + + GlobalScope.launch(Dispatchers.Main) { + account.saveable.observeForever(saveListener) + } + } + + private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { + GlobalScope.launch(Dispatchers.IO) { + localPreferences.saveToEncryptedStorage(it.account) + } + } + + fun logOff() { + val state = accountContent.value + + when (state) { + is AccountState.LoggedIn -> { + GlobalScope.launch(Dispatchers.Main) { + state.account.saveable.removeObserver(saveListener) + } + } + is AccountState.LoggedInViewOnly -> { + GlobalScope.launch(Dispatchers.Main) { + state.account.saveable.removeObserver(saveListener) + } + } + else -> {} + } + + _accountContent.update { AccountState.LoggedOff } + + localPreferences.clearEncryptedStorage() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt index 77080f6479..950430cc19 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt @@ -8,7 +8,7 @@ abstract class Card() { abstract fun id(): String } -class BadgeCard(val note: Note): Card() { +class BadgeCard(val note: Note) : Card() { override fun createdAt(): Long { return note.createdAt() ?: 0 } @@ -16,7 +16,7 @@ class BadgeCard(val note: Note): Card() { override fun id() = note.idHex } -class NoteCard(val note: Note): Card() { +class NoteCard(val note: Note) : Card() { override fun createdAt(): Long { return note.createdAt() ?: 0 } @@ -24,7 +24,7 @@ class NoteCard(val note: Note): Card() { override fun id() = note.idHex } -class LikeSetCard(val note: Note, val likeEvents: List): Card() { +class LikeSetCard(val note: Note, val likeEvents: List) : Card() { val createdAt = likeEvents.maxOf { it.createdAt() ?: 0 } override fun createdAt(): Long { return createdAt @@ -32,7 +32,7 @@ class LikeSetCard(val note: Note, val likeEvents: List): Card() { override fun id() = note.idHex + "L" + createdAt } -class ZapSetCard(val note: Note, val zapEvents: Map): Card() { +class ZapSetCard(val note: Note, val zapEvents: Map) : Card() { val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 } override fun createdAt(): Long { return createdAt @@ -40,10 +40,10 @@ class ZapSetCard(val note: Note, val zapEvents: Map): Card() { override fun id() = note.idHex + "Z" + createdAt } -class MultiSetCard(val note: Note, val boostEvents: List, val likeEvents: List, val zapEvents: Map): Card() { +class MultiSetCard(val note: Note, val boostEvents: List, val likeEvents: List, val zapEvents: Map) : Card() { val createdAt = maxOf( - zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0 , - likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0 , + zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0, + likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0 ) @@ -53,7 +53,7 @@ class MultiSetCard(val note: Note, val boostEvents: List, val likeEvents: override fun id() = note.idHex + "X" + createdAt } -class BoostSetCard(val note: Note, val boostEvents: List): Card() { +class BoostSetCard(val note: Note, val boostEvents: List) : Card() { val createdAt = boostEvents.maxOf { it.createdAt() ?: 0 } override fun createdAt(): Long { @@ -64,8 +64,8 @@ class BoostSetCard(val note: Note, val boostEvents: List): Card() { } sealed class CardFeedState { - object Loading: CardFeedState() - class Loaded(val feed: MutableState>): CardFeedState() - object Empty: CardFeedState() - class FeedError(val errorMessage: String): CardFeedState() + object Loading : CardFeedState() + class Loaded(val feed: MutableState>) : CardFeedState() + object Empty : CardFeedState() + class FeedError(val errorMessage: String) : CardFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index cb8a707bae..7bd61ec1d4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -44,7 +44,7 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode state = swipeRefreshState, onRefresh = { isRefreshing = true - }, + } ) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index b3eb028ac5..b7f9b17702 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -13,7 +13,6 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -24,10 +23,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean -class NotificationViewModel: CardFeedViewModel(NotificationFeedFilter) +class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter) -open class CardFeedViewModel(val dataSource: FeedFilter): ViewModel() { +open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(CardFeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -63,18 +63,19 @@ open class CardFeedViewModel(val dataSource: FeedFilter): ViewModel() { private fun convertToCard(notes: List): List { val reactionsPerEvent = mutableMapOf>() notes - .filter { it.event is ReactionEvent} + .filter { it.event is ReactionEvent } .forEach { val reactedPost = it.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent } - if (reactedPost != null) + if (reactedPost != null) { reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it) + } } - //val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) } + // val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) } val zapsPerEvent = mutableMapOf>() notes - .filter { it.event is LnZapEvent} + .filter { it.event is LnZapEvent } .forEach { zapEvent -> val zappedPost = zapEvent.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent } if (zappedPost != null) { @@ -85,33 +86,36 @@ open class CardFeedViewModel(val dataSource: FeedFilter): ViewModel() { } } - //val zapCards = zapsPerEvent.map { ZapSetCard(it.key, it.value) } + // val zapCards = zapsPerEvent.map { ZapSetCard(it.key, it.value) } val boostsPerEvent = mutableMapOf>() notes .filter { it.event is RepostEvent } .forEach { val boostedPost = it.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent } - if (boostedPost != null) + if (boostedPost != null) { boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it) + } } - //val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) } + // val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) } val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys val multiCards = allBaseNotes.map { - MultiSetCard(it, + MultiSetCard( + it, boostsPerEvent.get(it) ?: emptyList(), reactionsPerEvent.get(it) ?: emptyList(), zapsPerEvent.get(it) ?: emptyMap() ) } - val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map { - if (it.event is BadgeAwardEvent) + val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map { + if (it.event is BadgeAwardEvent) { BadgeCard(it) - else + } else { NoteCard(it) + } } return (multiCards + textNoteCards).sortedBy { it.createdAt() }.reversed() @@ -163,4 +167,4 @@ open class CardFeedViewModel(val dataSource: FeedFilter): ViewModel() { LocalCache.live.removeObserver(cacheListener) super.onCleared() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index d6da47339f..6763b4d7ce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -21,7 +21,7 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?, onWantsToReply: (Note) -> Unit ) { +fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?, onWantsToReply: (Note) -> Unit) { val feedState by viewModel.feedContent.collectAsState() var isRefreshing by remember { mutableStateOf(false) } @@ -50,8 +50,9 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode } is FeedState.Loaded -> { LaunchedEffect(state.feed.value.firstOrNull()) { - if (listState.firstVisibleItemIndex <= 1) + if (listState.firstVisibleItemIndex <= 1) { listState.animateScrollToItem(0) + } } LazyColumn( @@ -74,4 +75,4 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index f056afb5be..1bc51f58de 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -51,7 +51,7 @@ fun ChatroomListFeedView( state = swipeRefreshState, onRefresh = { isRefreshing = true - }, + } ) { Column() { Crossfade( @@ -89,7 +89,7 @@ private fun FeedLoaded( state: FeedState.Loaded, accountViewModel: AccountViewModel, navController: NavController, - markAsRead: MutableState, + markAsRead: MutableState ) { val listState = rememberLazyListState() @@ -119,7 +119,7 @@ private fun FeedLoaded( route = "Room/${userToComposeOn.pubkeyHex}" } - notificationCache.cache.markAsRead(route, it.createdAt, context) + notificationCache.cache.markAsRead(route, it.createdAt(), context) } } markAsRead.value = false @@ -135,7 +135,8 @@ private fun FeedLoaded( ) { itemsIndexed( state.feed.value, - key = { index, item -> if (index == 0) index else item.idHex }) { index, item -> + key = { index, item -> if (index == 0) index else item.idHex } + ) { index, item -> ChatroomCompose( item, accountViewModel = accountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt index 4f058ac274..e0adc1bb7e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt @@ -3,10 +3,9 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.MutableState import com.vitorpamplona.amethyst.model.Note - sealed class FeedState { object Loading : FeedState() class Loaded(val feed: MutableState>) : FeedState() object Empty : FeedState() class FeedError(val errorMessage: String) : FeedState() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index e78a451e92..be08602fb7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -30,7 +30,6 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.delay @Composable fun FeedView( @@ -55,9 +54,8 @@ fun FeedView( state = swipeRefreshState, onRefresh = { isRefreshing = true - }, + } ) { - Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { @@ -123,7 +121,7 @@ fun LoadingFeed() { .fillMaxHeight() .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center ) { Text(stringResource(R.string.loading_feed)) } @@ -136,7 +134,7 @@ fun FeedError(errorMessage: String, onRefresh: () -> Unit) { .fillMaxHeight() .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center ) { Text("${stringResource(R.string.error_loading_replies)} $errorMessage") Button( @@ -155,11 +153,11 @@ fun FeedEmpty(onRefresh: () -> Unit) { .fillMaxHeight() .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center ) { Text(stringResource(R.string.feed_is_empty)) OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 98de7538de..d772757805 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -17,7 +17,6 @@ import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -28,21 +27,21 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean -class NostrChannelFeedViewModel: FeedViewModel(ChannelFeedFilter) -class NostrChatRoomFeedViewModel: FeedViewModel(ChatroomFeedFilter) -class NostrGlobalFeedViewModel: FeedViewModel(GlobalFeedFilter) -class NostrThreadFeedViewModel: FeedViewModel(ThreadFeedFilter) -class NostrUserProfileNewThreadsFeedViewModel: FeedViewModel(UserProfileNewThreadFeedFilter) -class NostrUserProfileConversationsFeedViewModel: FeedViewModel(UserProfileConversationsFeedFilter) -class NostrUserProfileReportFeedViewModel: FeedViewModel(UserProfileReportsFeedFilter) -class NostrChatroomListKnownFeedViewModel: FeedViewModel(ChatroomListKnownFeedFilter) -class NostrChatroomListNewFeedViewModel: FeedViewModel(ChatroomListNewFeedFilter) -class NostrHomeFeedViewModel: FeedViewModel(HomeNewThreadFeedFilter) -class NostrHomeRepliesFeedViewModel: FeedViewModel(HomeConversationsFeedFilter) +class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter) +class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter) +class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter) +class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter) +class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter) +class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter) +class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter) +class NostrChatroomListKnownFeedViewModel : FeedViewModel(ChatroomListKnownFeedFilter) +class NostrChatroomListNewFeedViewModel : FeedViewModel(ChatroomListNewFeedFilter) +class NostrHomeFeedViewModel : FeedViewModel(HomeNewThreadFeedFilter) +class NostrHomeRepliesFeedViewModel : FeedViewModel(HomeConversationsFeedFilter) - -abstract class FeedViewModel(val localFilter: FeedFilter): ViewModel() { +abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(FeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -118,4 +117,4 @@ abstract class FeedViewModel(val localFilter: FeedFilter): ViewModel() { LocalCache.live.removeObserver(cacheListener) super.onCleared() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt index bedef2bf83..e9fabd3bb4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt @@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Note sealed class LnZapFeedState { object Loading : LnZapFeedState() - class Loaded(val feed: MutableState>>): LnZapFeedState() + class Loaded(val feed: MutableState>>) : LnZapFeedState() object Empty : LnZapFeedState() class FeedError(val errorMessage: String) : LnZapFeedState() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt index 3dfae83bfe..4570bef666 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt @@ -39,7 +39,7 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo state = swipeRefreshState, onRefresh = { isRefreshing = true - }, + } ) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 5b1a70da9d..1b92b2ec08 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -18,10 +17,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean -class NostrUserProfileZapsFeedViewModel: LnZapFeedViewModel(UserProfileZapsFeedFilter) +class NostrUserProfileZapsFeedViewModel : LnZapFeedViewModel(UserProfileZapsFeedFilter) -open class LnZapFeedViewModel(val dataSource: FeedFilter>): ViewModel() { +open class LnZapFeedViewModel(val dataSource: FeedFilter>) : ViewModel() { private val _feedContent = MutableStateFlow(LnZapFeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -46,7 +46,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>): Vie } } - private fun updateFeed(notes: List>) { + private fun updateFeed(notes: List>) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { val currentState = feedContent.value @@ -92,4 +92,4 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>): Vie LocalCache.live.removeObserver(cacheListener) super.onCleared() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt index 99e2d40e4c..2461b982f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.ui.actions.NewRelayListView import com.vitorpamplona.amethyst.ui.note.RelayCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -36,8 +35,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean -class RelayFeedViewModel: ViewModel() { +class RelayFeedViewModel : ViewModel() { val order = compareByDescending { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url } private val _feedContent = MutableStateFlow>(emptyList()) @@ -112,11 +112,12 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) var wantsToAddRelay by remember { - mutableStateOf( "") + mutableStateOf("") } - if (wantsToAddRelay.isNotEmpty()) + if (wantsToAddRelay.isNotEmpty()) { NewRelayListView({ wantsToAddRelay = "" }, account, wantsToAddRelay) + } LaunchedEffect(isRefreshing) { if (isRefreshing) { @@ -129,7 +130,7 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo state = swipeRefreshState, onRefresh = { isRefreshing = true - }, + } ) { Column() { val listState = rememberLazyListState() @@ -142,7 +143,8 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo state = listState ) { itemsIndexed(feedState, key = { _, item -> item.url }) { index, item -> - RelayCompose(item, + RelayCompose( + item, accountViewModel = accountViewModel, navController = navController, onAddRelay = { wantsToAddRelay = item.url }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt index 98a534b82a..36606456c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt @@ -5,11 +5,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.map import com.vitorpamplona.amethyst.service.relays.RelayPool -class RelayPoolViewModel: ViewModel() { - val connectedRelaysLiveData: LiveData = RelayPool.live.map { - it.relays.connectedRelays() - } - val availableRelaysLiveData: LiveData = RelayPool.live.map { - it.relays.availableRelays() - } -} \ No newline at end of file +class RelayPoolViewModel : ViewModel() { + val connectedRelaysLiveData: LiveData = RelayPool.live.map { + it.relays.connectedRelays() + } + val availableRelaysLiveData: LiveData = RelayPool.live.map { + it.relays.availableRelays() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 0a9cacc227..fc40ed0ad8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -35,13 +35,22 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import coil.compose.AsyncImage import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture @@ -52,16 +61,6 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.delay -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent -import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.ui.note.BadgeDisplay @Composable fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { @@ -83,7 +82,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A state = swipeRefreshState, onRefresh = { isRefreshing = true - }, + } ) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> @@ -102,12 +101,12 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A LaunchedEffect(noteId) { // waits to load the thread to scroll to item. delay(100) - val noteForPosition = state.feed.value.filter { it.idHex == noteId}.firstOrNull() + val noteForPosition = state.feed.value.filter { it.idHex == noteId }.firstOrNull() var position = state.feed.value.indexOf(noteForPosition) if (position >= 0) { if (position >= 1 && position < state.feed.value.size - 1) { - position -- // show the replying note + position-- // show the replying note } listState.animateScrollToItem(position) @@ -122,8 +121,9 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A state = listState ) { itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item -> - if (index == 0) - NoteMaster(item, + if (index == 0) { + NoteMaster( + item, modifier = Modifier.drawReplyLevel( item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f), @@ -132,7 +132,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A accountViewModel = accountViewModel, navController = navController ) - else { + } else { Column() { Row() { NoteCompose( @@ -146,7 +146,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A isBoostedNote = false, unPackReply = false, accountViewModel = accountViewModel, - navController = navController, + navController = navController ) } } @@ -188,10 +188,11 @@ fun Modifier.drawReplyLevel(level: Int, color: Color, selected: Color): Modifier .padding(start = (2 + (level * 3)).dp) @Composable -fun NoteMaster(baseNote: Note, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - navController: NavController +fun NoteMaster( + baseNote: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + navController: NavController ) { val noteState by baseNote.live().metadata.observeAsState() val note = noteState?.note @@ -225,14 +226,16 @@ fun NoteMaster(baseNote: Note, Column( modifier .fillMaxWidth() - .padding(top = 10.dp)) { - Row(modifier = Modifier - .padding(start = 12.dp, end = 12.dp) - .clickable(onClick = { - note.author?.let { - navController.navigate("User/${it.pubkeyHex}") - } - }) + .padding(top = 10.dp) + ) { + Row( + modifier = Modifier + .padding(start = 12.dp, end = 12.dp) + .clickable(onClick = { + note.author?.let { + navController.navigate("User/${it.pubkeyHex}") + } + }) ) { NoteAuthorPicture( note = baseNote, @@ -259,7 +262,7 @@ fun NoteMaster(baseNote: Note, imageVector = Icons.Default.MoreVert, null, modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) NoteDropDownMenu(baseNote, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel) @@ -271,7 +274,7 @@ fun NoteMaster(baseNote: Note, } if (noteEvent is BadgeDefinitionEvent) { - Spacer(modifier = Modifier.padding(top=10.dp)) + Spacer(modifier = Modifier.padding(top = 10.dp)) BadgeDisplay(baseNote = note) } else if (noteEvent is LongTextNoteEvent) { Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) { @@ -313,18 +316,18 @@ fun NoteMaster(baseNote: Note, Row(modifier = Modifier.padding(horizontal = 12.dp)) { Column() { - val eventContent = note.event?.content + val eventContent = note.event?.content() - val canPreview = note.author == account.userProfile() - || (note.author?.let { account.userProfile().isFollowing(it) } ?: true ) - || !noteForReports.hasAnyReports() + val canPreview = note.author == account.userProfile() || + (note.author?.let { account.userProfile().isFollowing(it) } ?: true) || + !noteForReports.hasAnyReports() if (eventContent != null) { TranslateableRichTextViewer( eventContent, canPreview, Modifier.fillMaxWidth(), - note.event?.tags, + note.event?.tags(), MaterialTheme.colors.background, accountViewModel, navController diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt index f5cd706e05..6a86f37e3d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt @@ -8,4 +8,4 @@ sealed class UserFeedState { class Loaded(val feed: MutableState>) : UserFeedState() object Empty : UserFeedState() class FeedError(val errorMessage: String) : UserFeedState() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 63aff48591..ed9b04b7ce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -39,7 +39,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode state = swipeRefreshState, onRefresh = { isRefreshing = true - }, + } ) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index d2ffb23ed8..8748a1f11c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -9,7 +9,6 @@ import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -20,12 +19,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean -class NostrUserProfileFollowsUserFeedViewModel: UserFeedViewModel(UserProfileFollowsFeedFilter) -class NostrUserProfileFollowersUserFeedViewModel: UserFeedViewModel(UserProfileFollowersFeedFilter) -class NostrHiddenAccountsFeedViewModel: UserFeedViewModel(HiddenAccountsFeedFilter) +class NostrUserProfileFollowsUserFeedViewModel : UserFeedViewModel(UserProfileFollowsFeedFilter) +class NostrUserProfileFollowersUserFeedViewModel : UserFeedViewModel(UserProfileFollowersFeedFilter) +class NostrHiddenAccountsFeedViewModel : UserFeedViewModel(HiddenAccountsFeedFilter) -open class UserFeedViewModel(val dataSource: FeedFilter): ViewModel() { +open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(UserFeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -96,4 +96,4 @@ open class UserFeedViewModel(val dataSource: FeedFilter): ViewModel() { LocalCache.live.removeObserver(cacheListener) super.onCleared() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index 702203c584..2d44856021 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -1,8 +1,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import android.app.Activity +import android.app.KeyguardManager import android.content.Context import android.content.ContextWrapper +import android.content.Intent +import android.os.Build import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL @@ -55,13 +63,13 @@ import nostr.postr.toNsec fun AccountBackupDialog(account: Account, onClose: () -> Unit) { Dialog( onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), + properties = DialogProperties(usePlatformDefaultWidth = false) ) { Surface(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .background(MaterialTheme.colors.background) - .fillMaxSize(), + .fillMaxSize() ) { Row( modifier = Modifier @@ -78,13 +86,13 @@ fun AccountBackupDialog(account: Account, onClose: () -> Unit) { .fillMaxSize() .padding(horizontal = 30.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center ) { MaterialRichText( - style = RichTextStyle().resolveDefaults(), + style = RichTextStyle().resolveDefaults() ) { Markdown( - content = stringResource(R.string.account_backup_tips_md), + content = stringResource(R.string.account_backup_tips_md) ) } @@ -105,12 +113,20 @@ private fun NSecCopyButton( val context = LocalContext.current val scope = rememberCoroutineScope() + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + copyNSec(context, scope, account, clipboardManager) + } + } + Button( modifier = Modifier.padding(horizontal = 3.dp), onClick = { - authenticatedCopyNSec(context, scope, account, clipboardManager) + authenticatedCopyNSec(context, scope, account, clipboardManager, keyguardLauncher) }, - shape = RoundedCornerShape(20.dp), colors = ButtonDefaults.buttonColors( + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.primary ), contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) @@ -141,10 +157,33 @@ private fun authenticatedCopyNSec( scope: CoroutineScope, account: Account, clipboardManager: ClipboardManager, + keyguardLauncher: ManagedActivityResultLauncher ) { val fragmentContext = context.getFragmentActivity()!! - val authenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL + val keyguardManager = + fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + + if (!keyguardManager.isDeviceSecure) { + copyNSec(context, scope, account, clipboardManager) + return + } + + fun keyguardPrompt() { + val intent = keyguardManager.createConfirmDeviceCredentialIntent( + context.getString(R.string.app_name_release), + context.getString(R.string.copy_my_secret_key) + ) + + keyguardLauncher.launch(intent) + } + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + keyguardPrompt() + return + } + val biometricManager = BiometricManager.from(context) + val authenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(context.getString(R.string.app_name_release)) @@ -155,6 +194,23 @@ private fun authenticatedCopyNSec( val biometricPrompt = BiometricPrompt( fragmentContext, object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + + when (errorCode) { + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> keyguardPrompt() + BiometricPrompt.ERROR_LOCKOUT -> keyguardPrompt() + else -> + scope.launch { + Toast.makeText( + context, + "${context.getString(R.string.biometric_error)}: $errString", + Toast.LENGTH_SHORT + ).show() + } + } + } + override fun onAuthenticationFailed() { super.onAuthenticationFailed() scope.launch { @@ -173,11 +229,9 @@ private fun authenticatedCopyNSec( } ) - val canAuth = biometricManager.canAuthenticate(authenticators) - if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) { - biometricPrompt.authenticate(promptInfo) - } else { - copyNSec(context, scope, account, clipboardManager) + when (biometricManager.canAuthenticate(authenticators)) { + BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) + else -> keyguardPrompt() } } @@ -185,7 +239,7 @@ private fun copyNSec( context: Context, scope: CoroutineScope, account: Account, - clipboardManager: ClipboardManager, + clipboardManager: ClipboardManager ) { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) @@ -197,4 +251,4 @@ private fun copyNSec( ).show() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index e17eaba90a..2734c6ee19 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -7,113 +7,133 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.map -import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.model.ReportEvent import java.util.Locale -class AccountViewModel(private val account: Account): ViewModel() { - val accountLiveData: LiveData = account.live.map { it } - val accountLanguagesLiveData: LiveData = account.liveLanguages.map { it } +class AccountViewModel(private val account: Account) : ViewModel() { + val accountLiveData: LiveData = account.live.map { it } + val accountLanguagesLiveData: LiveData = account.liveLanguages.map { it } - fun isWriteable(): Boolean { - return account.isWriteable() - } - - fun userProfile(): User { - return account.userProfile() - } - - fun reactTo(note: Note) { - account.reactTo(note) - } - - fun hasReactedTo(baseNote: Note): Boolean { - return account.hasReacted(baseNote) - } - - fun deleteReactionTo(note: Note) { - account.delete(account.reactionTo(note)) - } - - fun hasBoosted(baseNote: Note): Boolean { - return account.hasBoosted(baseNote) - } - - fun deleteBoostsTo(note: Note) { - account.delete(account.boostsTo(note)) - } - - fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit) { - val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() - - if (lud16.isNullOrBlank()) { - onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)) - return + fun isWriteable(): Boolean { + return account.isWriteable() } - val zapRequest = account.createZapRequestFor(note) + fun userProfile(): User { + return account.userProfile() + } - LightningAddressResolver().lnAddressInvoice(lud16, amount, message, zapRequest?.toJson(), - onSuccess = { - runCatching { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) - ContextCompat.startActivity(context, intent, null) + fun reactTo(note: Note) { + account.reactTo(note) + } + + fun hasReactedTo(baseNote: Note): Boolean { + return account.hasReacted(baseNote) + } + + fun deleteReactionTo(note: Note) { + account.delete(account.reactionTo(note)) + } + + fun hasBoosted(baseNote: Note): Boolean { + return account.hasBoosted(baseNote) + } + + fun deleteBoostsTo(note: Note) { + account.delete(account.boostsTo(note)) + } + + fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit) { + val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() + + if (lud16.isNullOrBlank()) { + onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)) + return } - }, - onError = onError - ) - } - fun report(note: Note, type: ReportEvent.ReportType) { - account.report(note, type) - } + val zapRequest = account.createZapRequestFor(note) - fun report(user: User, type: ReportEvent.ReportType) { - account.report(user, type) - } + LightningAddressResolver().lnAddressInvoice( + lud16, + amount, + message, + zapRequest?.toJson(), + onSuccess = { + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) + ContextCompat.startActivity(context, intent, null) + } + }, + onError = onError + ) + } - fun boost(note: Note) { - account.boost(note) - } + fun report(note: Note, type: ReportEvent.ReportType) { + account.report(note, type) + } - fun broadcast(note: Note) { - account.broadcast(note) - } + fun report(user: User, type: ReportEvent.ReportType) { + account.report(user, type) + } - fun delete(note: Note) { - account.delete(note) - } + fun boost(note: Note) { + account.boost(note) + } - fun decrypt(note: Note): String? { - return account.decryptContent(note) - } + fun broadcast(note: Note) { + account.broadcast(note) + } - fun hide(user: User, ctx: Context) { - account.hideUser(user.pubkeyHex) - } + fun delete(note: Note) { + account.delete(note) + } - fun show(user: User, ctx: Context) { - account.showUser(user.pubkeyHex) - } + fun decrypt(note: Note): String? { + return account.decryptContent(note) + } - fun translateTo(lang: Locale, ctx: Context) { - account.updateTranslateTo(lang.language) - } + fun hide(user: User, ctx: Context) { + account.hideUser(user.pubkeyHex) + } - fun dontTranslateFrom(lang: String, ctx: Context) { - account.addDontTranslateFrom(lang) - } + fun show(user: User, ctx: Context) { + account.showUser(user.pubkeyHex) + } - fun prefer(source: String, target: String, preference: String) { - account.prefer(source, target, preference) - } + fun translateTo(lang: Locale, ctx: Context) { + account.updateTranslateTo(lang.language) + } - fun follow(user: User) { - account.follow(user) - } -} \ No newline at end of file + fun dontTranslateFrom(lang: String, ctx: Context) { + account.addDontTranslateFrom(lang) + } + + fun prefer(source: String, target: String, preference: String) { + account.prefer(source, target, preference) + } + + fun follow(user: User) { + account.follow(user) + } + + fun unfollow(user: User) { + account.unfollow(user) + } + + fun isFollowing(user: User): Boolean { + return account.userProfile().isFollowing(user) + } + + fun hideDeleteRequestInfo(): Boolean { + return account.hideDeleteRequestInfo + } + + fun setHideDeleteRequestInfo() { + account.setHideDeleteRequestInfo() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 7fc6aff8b8..71fa2ee6cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -120,7 +120,8 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun Column(Modifier.fillMaxHeight()) { ChannelHeader( - channel, account, + channel, + account, navController = navController ) @@ -130,7 +131,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun .padding(vertical = 0.dp) .weight(1f, true) ) { - ChatroomFeedView(feedViewModel, accountViewModel, navController, "Channel/${channelId}") { + ChatroomFeedView(feedViewModel, accountViewModel, navController, "Channel/$channelId") { replyTo.value = it } } @@ -169,8 +170,9 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun } } - //LAST ROW - Row(modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top= 5.dp).fillMaxWidth(), + // LAST ROW + Row( + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -221,7 +223,6 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont Column() { Column(modifier = Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - AsyncImageProxy( model = ResizeImage(channel.profilePicture(), 35.dp), placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)), @@ -234,13 +235,15 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont .clip(shape = CircleShape) ) - Column(modifier = Modifier - .padding(start = 10.dp) - .weight(1f)) { + Column( + modifier = Modifier + .padding(start = 10.dp) + .weight(1f) + ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( "${channel.info.name}", - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Bold ) } @@ -255,9 +258,11 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont } } - Row(modifier = Modifier - .height(35.dp) - .padding(bottom = 3.dp)) { + Row( + modifier = Modifier + .height(35.dp) + .padding(bottom = 3.dp) + ) { NoteCopyButton(channel) if (channel.creator == account.userProfile()) { @@ -269,7 +274,6 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont } else { JoinButton(account, channel, navController) } - } } } @@ -281,7 +285,6 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont } } - @Composable private fun NoteCopyButton( note: Channel @@ -298,7 +301,7 @@ private fun NoteCopyButton( colors = ButtonDefaults .buttonColors( backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ), + ) ) { Icon( tint = Color.White, @@ -323,8 +326,9 @@ private fun EditButton(account: Account, channel: Channel) { mutableStateOf(false) } - if (wantsToPost) + if (wantsToPost) { NewChannelView({ wantsToPost = false }, account = account, channel) + } Button( modifier = Modifier @@ -381,4 +385,4 @@ private fun LeaveButton(account: Account, channel: Channel, navController: NavCo ) { Text(text = stringResource(R.string.leave), color = Color.White) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index 07f66a720d..b416c2f2f0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -74,7 +74,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), color = MaterialTheme.colors.primary ) - }, + } ) { Tab( selected = pagerState.currentPage == 0, @@ -95,7 +95,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon IconButton( modifier = Modifier - .padding(end=5.dp) + .padding(end = 5.dp) .size(40.dp) .align(Alignment.CenterEnd), onClick = { moreActionsExpanded = true } @@ -103,21 +103,21 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon Icon( imageVector = Icons.Default.MoreVert, contentDescription = null, - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) ChatroomTabMenu( moreActionsExpanded, { moreActionsExpanded = false }, { markKnownAsRead.value = true }, - { markNewAsRead.value = true }, + { markNewAsRead.value = true } ) } } HorizontalPager(count = 2, state = pagerState) { when (pagerState.currentPage) { - 0 -> TabKnown(accountViewModel, navController, markKnownAsRead,) + 0 -> TabKnown(accountViewModel, navController, markKnownAsRead) 1 -> TabNew(accountViewModel, navController, markNewAsRead) } } @@ -130,7 +130,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon fun TabKnown( accountViewModel: AccountViewModel, navController: NavController, - markAsRead: MutableState, + markAsRead: MutableState ) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -213,7 +213,7 @@ fun ChatroomTabMenu( expanded: Boolean, onDismiss: () -> Unit, onMarkKnownAsRead: () -> Unit, - onMarkNewAsRead: () -> Unit, + onMarkNewAsRead: () -> Unit ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { DropdownMenuItem(onClick = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 17bda6fcf8..c4736e7605 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -116,7 +116,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr .padding(vertical = 0.dp) .weight(1f, true) ) { - ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/${userId}") { + ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/$userId") { replyTo.value = it } } @@ -155,9 +155,10 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr } } - //LAST ROW - Row(modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) - .fillMaxWidth(), + // LAST ROW + Row( + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) + .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -198,18 +199,17 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr } } - @Composable fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { val ctx = LocalContext.current.applicationContext - Column(modifier = Modifier.clickable( + Column( + modifier = Modifier.clickable( onClick = { navController.navigate("User/${baseUser.pubkeyHex}") } ) ) { Column(modifier = Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - val authorState by baseUser.live().metadata.observeAsState() val author = authorState?.user!! @@ -242,4 +242,4 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro thickness = 0.25.dp ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt index bc35e3740b..8f9484d363 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt @@ -51,7 +51,7 @@ fun FiltersScreen(accountViewModel: AccountViewModel, navController: NavControll Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), color = MaterialTheme.colors.primary ) - }, + } ) { Tab( selected = pagerState.currentPage == 0, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 466ad2959c..fb0809f793 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -27,7 +27,6 @@ import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.pagerTabIndicatorOffset import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter @@ -87,7 +86,7 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), color = MaterialTheme.colors.primary ) - }, + } ) { Tab( selected = pagerState.currentPage == 0, @@ -113,4 +112,4 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 3e41cd27a9..fa698b2759 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -92,4 +92,4 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index ca07242858..3f438c05d8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -53,4 +53,4 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController, Route.Notification.route) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 75b24de111..fafccb56b4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -71,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter -import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.FeedView @@ -144,11 +143,12 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro val coroutineScope = rememberCoroutineScope() val scrollState = rememberScrollState() - Column(modifier = Modifier - .fillMaxSize() - .onSizeChanged { - columnSize = it - } + Column( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { + columnSize = it + } ) { Box( modifier = Modifier @@ -216,7 +216,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro val userState by baseUser.live().reports.observeAsState() val userReports = userState?.user?.reports?.values?.flatten()?.count() - Text(text = "${userReports.toString()} ${stringResource(R.string.reports)}") + Text(text = "$userReports ${stringResource(R.string.reports)}") }, { val userState by baseUser.live().relays.observeAsState() @@ -279,11 +279,12 @@ private fun ProfileHeader( Box { DrawBanner(baseUser) - Box(modifier = Modifier - .padding(horizontal = 10.dp) - .size(40.dp) - .align(Alignment.TopEnd)) { - + Box( + modifier = Modifier + .padding(horizontal = 10.dp) + .size(40.dp) + .align(Alignment.TopEnd) + ) { Button( modifier = Modifier .size(30.dp) @@ -299,12 +300,11 @@ private fun ProfileHeader( Icon( tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more_options), + contentDescription = stringResource(R.string.more_options) ) UserProfileDropDownMenu(baseUser, popupExpanded, { popupExpanded = false }, accountViewModel) } - } Column( @@ -313,12 +313,10 @@ private fun ProfileHeader( .padding(horizontal = 10.dp) .padding(top = 75.dp) ) { - Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom ) { - UserPicture( baseUser = baseUser, baseUserAccount = account.userProfile(), @@ -326,8 +324,9 @@ private fun ProfileHeader( pictureModifier = Modifier.border( 3.dp, MaterialTheme.colors.background, - CircleShape), - onClick = { + CircleShape + ), + onClick = { if (baseUser.profilePicture() != null) { zoomImageDialogOpen = true } @@ -343,9 +342,11 @@ private fun ProfileHeader( Spacer(Modifier.weight(1f)) - Row(modifier = Modifier - .height(35.dp) - .padding(bottom = 3.dp)) { + Row( + modifier = Modifier + .height(35.dp) + .padding(bottom = 3.dp) + ) { MessageButton(baseUser, navController) NPubCopyButton(baseUser) @@ -390,7 +391,8 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController: Row(verticalAlignment = Alignment.Bottom) { user.bestDisplayName()?.let { - Text(it, + Text( + it, modifier = Modifier.padding(top = 7.dp), fontWeight = FontWeight.Bold, fontSize = 25.sp @@ -498,7 +500,6 @@ fun BadgeThumb( } } - @Composable fun BadgeThumb( baseNote: Note, @@ -517,7 +518,8 @@ fun BadgeThumb( Box( Modifier .width(size) - .height(size)) { + .height(size) + ) { if (image == null) { Image( painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")), @@ -538,10 +540,11 @@ fun BadgeThumb( .clip(shape = CircleShape) .background(MaterialTheme.colors.background) .run { - if (onClick != null) - this.clickable(onClick = { onClick(note) } ) - else + if (onClick != null) { + this.clickable(onClick = { onClick(note) }) + } else { this + } } ) @@ -577,7 +580,7 @@ private fun DrawBanner(baseUser: User) { ) if (zoomImageDialogOpen) { - ZoomableImageDialog(imageUrl = banner, onDismiss = {zoomImageDialogOpen = false}) + ZoomableImageDialog(imageUrl = banner, onDismiss = { zoomImageDialogOpen = false }) } } else { Image( @@ -740,8 +743,6 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: Nav } } - - @Composable private fun NPubCopyButton( user: User @@ -758,7 +759,7 @@ private fun NPubCopyButton( colors = ButtonDefaults .buttonColors( backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ), + ) ) { Icon( tint = Color.White, @@ -788,7 +789,7 @@ private fun MessageButton(user: User, navController: NavController) { colors = ButtonDefaults .buttonColors( backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ), + ) ) { Icon( painter = painterResource(R.drawable.ic_dm), @@ -805,8 +806,9 @@ private fun EditButton(account: Account) { mutableStateOf(false) } - if (wantsToEdit) + if (wantsToEdit) { NewUserMetadataView({ wantsToEdit = false }, account) + } Button( modifier = Modifier @@ -875,7 +877,6 @@ fun ShowUserButton(onClick: () -> Unit) { } } - @Composable fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) { val clipboardManager = LocalClipboardManager.current @@ -892,7 +893,7 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () -> Text(stringResource(R.string.copy_user_id)) } - if ( account.userProfile() != user) { + if (account.userProfile() != user) { Divider() if (account.isHidden(user)) { DropdownMenuItem(onClick = { @@ -948,4 +949,4 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () -> } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index af3db3b05a..444e462711 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -68,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.screen.FeedView import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -146,21 +145,21 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont LaunchedEffect(Unit) { // Wait for text changes to stop for 300 ms before firing off search. - withContext(Dispatchers.IO){ + withContext(Dispatchers.IO) { searchTextChanges.receiveAsFlow() .filter { it.isNotBlank() } .distinctUntilChanged() .debounce(300) .collectLatest { - if (it.removePrefix("npub").removePrefix("note").length >= 4) + if (it.removePrefix("npub").removePrefix("note").length >= 4) { onlineSearch.search(it.trim()) + } searchResults.value = LocalCache.findUsersStartingWith(it) searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed() searchResultsChannels.value = LocalCache.findChannelsStartingWith(it) } } - } DisposableEffect(Unit) { @@ -169,7 +168,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont } } - //LAST ROW + // LAST ROW Row( modifier = Modifier .padding(10.dp) @@ -233,7 +232,6 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont ) } - if (searchValue.isNotBlank()) { LazyColumn( modifier = Modifier.fillMaxHeight(), @@ -242,11 +240,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont bottom = 10.dp ) ) { - itemsIndexed(searchResults.value, key = { _, item -> "u"+item.pubkeyHex }) { index, item -> + itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { index, item -> UserCompose(item, accountViewModel = accountViewModel, navController = navController) } - itemsIndexed(searchResultsChannels.value, key = { _, item -> "c"+item.idHex }) { index, item -> + itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { index, item -> ChannelName( channelPicture = item.profilePicture(), channelPicturePlaceholder = BitmapPainter(RoboHashCache.get(ctx, item.idHex)), @@ -259,10 +257,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont channelLastTime = null, channelLastContent = item.info.about, false, - onClick = { navController.navigate("Channel/${item.idHex}") }) + onClick = { navController.navigate("Channel/${item.idHex}") } + ) } - itemsIndexed(searchResultsNotes.value, key = { _, item -> "n"+item.idHex }) { index, item -> + itemsIndexed(searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { index, item -> NoteCompose(item, accountViewModel = accountViewModel, navController = navController) } } @@ -275,9 +274,10 @@ fun UserLine( account: Account, onClick: () -> Unit ) { - Column(modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) ) { Row( modifier = Modifier @@ -287,7 +287,6 @@ fun UserLine( top = 10.dp ) ) { - UserPicture(baseUser, account.userProfile(), 55.dp, Modifier, null) Column( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index d03384734c..0186afcd44 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -6,7 +6,7 @@ val Purple200 = Color(0xFFBB86FC) val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) -val BitcoinOrange = Color (0xFFF7931A) +val BitcoinOrange = Color(0xFFF7931A) val Following = Color(0xFF03DAC5) val Nip05 = Color(0xFF01BAFF) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 809cbc6b90..a9542cd24c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -5,7 +5,7 @@ import androidx.compose.material.Shapes import androidx.compose.ui.unit.dp val Shapes = Shapes( - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(4.dp), - large = RoundedCornerShape(0.dp) -) \ No newline at end of file + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) +) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index b40bbbe3f8..ed7b30ae46 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -11,15 +11,15 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView private val DarkColorPalette = darkColors( - primary = Purple200, - primaryVariant = Purple700, - secondary = Teal200, + primary = Purple200, + primaryVariant = Purple700, + secondary = Teal200 ) private val LightColorPalette = lightColors( - primary = Purple500, - primaryVariant = Purple700, - secondary = Teal200, + primary = Purple500, + primaryVariant = Purple700, + secondary = Teal200 /* Other default colors to override background = Color.White, @@ -33,24 +33,24 @@ private val LightColorPalette = lightColors( @Composable fun AmethystTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) { - DarkColorPalette - } else { - LightColorPalette - } - - MaterialTheme( - colors = colors, - typography = Typography, - shapes = Shapes, - content = content - ) - - val view = LocalView.current - if (!view.isInEditMode && darkTheme) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colors.background.toArgb() + val colors = if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette } - } -} \ No newline at end of file + + MaterialTheme( + colors = colors, + typography = Typography, + shapes = Shapes, + content = content + ) + + val view = LocalView.current + if (!view.isInEditMode && darkTheme) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colors.background.toArgb() + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index c5a8ccc945..18ff3dd594 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -8,11 +8,11 @@ import androidx.compose.ui.unit.sp // Set of Material typography styles to start with val Typography = Typography( - body1 = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp - ) + body1 = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ) /* Other default text styles to override button = TextStyle( fontFamily = FontFamily.Default, @@ -25,4 +25,4 @@ val Typography = Typography( fontSize = 12.sp ) */ -) \ No newline at end of file +) diff --git a/app/src/main/res/drawable/text_select_move_forward_character.xml b/app/src/main/res/drawable/text_select_move_forward_character.xml new file mode 100644 index 0000000000..312d40dbcd --- /dev/null +++ b/app/src/main/res/drawable/text_select_move_forward_character.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 424d876b52..e9f0687ff5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -66,8 +66,8 @@ Mon groupe spectaculaire Url de l\'image Description - "à propos.. " - Qu\'es ce qui vous préoccupe ? + à propos + Quoi de neuf ? Envoyer Sauvegarder Créer @@ -85,7 +85,7 @@ Mon nom visible du public Nom d\'utilisateur Mon nom d\'utilisateur - à propos de moi + Biographie URL de l\'avatar URL de la bannière URL de site @@ -94,14 +94,14 @@ Image enregistrée dans la galerie Échec de l\'enregistrement de l\'image Charger une image - Chargement en cours... + Chargement... L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats "Répondre ici.. " Copie l\'ID de note dans le presse-papiers pour le partage Copier l\'ID de la chaîne (Note) dans le presse-papiers Modifie les métadonnées du canal Joindre - Connu + Connus Nouvelles demandes Utilisateurs bloqués Nouveaux sujets @@ -131,7 +131,7 @@ Révéler le mot de passe Cacher le mot de passe Clé non valide - "J\accepte le(s) " + "J\'accepte les " conditions d\'utilisation L\'acceptation des conditions est requise La clé est requise @@ -159,9 +159,9 @@ Auto traduit de vers - Montre - premier - Toujours traduire en + Montre en + en premier + "Toujours traduire en " NIP-05 LNURL... jamais diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000000..33c91736af --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,196 @@ + + + Amethyst + Amethyst Debug + Mutass a QR kódra + QR kód megjelenítése + Profil kép + QR kód beolvasása + Mutasd + A bejegyzést nem megfelelőként jelölte meg + Bejegyzés nem található + Csatorna Kép + A hivatkozott esemény nem található + Az üzenet dekódolása sikertelen + Csoport Kép + Szókimondó tartalom + Spam + Megszemélyesítés + Illegális viselkedés + 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 + + Spam / Csalás bejelentés + Megszemélyesítés bejelentés + Szókimondó tartalom bejelentés + Illegális viselkedés bejelentés + 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 + Zap-ek + Megtekintések száma + Megosztás + Megosztva + Idézet + Új összeg sats-ban + Hozzáad + "válasz erre " + " és " + "csatornában " + Profil Banner + " Követek" + " Követők" + Profil + Biztonsági filterek + Kijelentkezés + Mutass még + Lightning Számla + Fizetés + Lightning Jatt + Üzenet a Fogadónak + Nagyon szépen köszönöm! + Összeg Sats-ban + Sats küldése + "Soha ne fordítsd erről " + "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 + Leírás + "Rólunk.. " + Mire gondolsz? + Hozzászólás + Mentés + Létrehoz + Törlés + Nem sikerült feltölteni a képet + Csomópont Címe + Hozzászólások + Hibák + 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 + Felhasználónév + Felhasználónevem + Rólam + Avatarom URL-je + Bannerem UR-jeL + Oldalam URL-je + LN Cím + LN URL (elavult) + A kép a galériába mentve + Nem sikerült menteni a képet + Kép feltöltése + Feltöltés… + A felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással + "válaszolj ide.. " + Megosztáshoz a bejegyzés azonosítót a vágólapra másolja + Bejegyzés azonosító vágólapra másolása + Szerkeszti a csatorna metaadatait + Csatlakozás + Ismert + Új kérések + Letiltott felhasználók + Új szálak + Beszélgetések + Bejegyzések + Válaszok + "Követek" + "Jelentések" + Több beállítás + " Csomópontok" + 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 + Közvetlen üzenet küldése + Szerkeszti a felhasználó metaadatait + Követés + Tiltás feloldása + 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ó megjelenítése + Jelszó elrejtése + Érvénytelen kulcs + "Elfogadom a " + használati feltételek + A feltételek elfogadása szükséges + Kulcs szükséges + Bejelentkezés + Új kulcs létrehozása + Lista betöltése + "Hiba a válaszok betöltésekor: " + Próbáld újra + A lista üres. + Frissítés + létrehozta + 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 + Leíratkozás + Csatorna létrehozva + "A csatornainformáció a következőre módosult" + Publikus Chat + beérkezett hozzászólások + Eltávolítás + sats + Automatikus + fordítás erről + erre + Mutasd ebben + első + Mindig fordítsa le + NIP-05 + LNURL... + 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 + Az összes ismert megjelölése olvasottként + Az összes új megjelölése olvasottként + Összes megjelölése olvasottként + + ## Kulcs- és biztonsági mentési tippek + \n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-al kezdődik. Bárki, aki hozzáfér az Ön titkos kulcsához, közzétehet tartalmat az Ön személyazonosságának használatával. + \n\n- **Ne** helyezze el titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik. + \n- Az Amethyst fejlesztők a titkos kulcsodat **soha** nem fogják elkérni. + \n- A fiók-helyreállításhoz, titkos kulcsáról **mindig** készítsen biztonságos biztonsági másolatot. Javasoljuk a jelszókezelő használatát. + + A titkos kulcs (nsec) a vágólapra másolva + A titkos kulcsom másolása + Hitelesítés nem sikerült + Hiba + "Készítette %1$s" + "Kitűző %1$s" + Új Jelvényt kapott + Kitüntetésben részesült + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000000..d94dbd541d --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,180 @@ + + + Amethyst + Amethyst Debug + QR Code\'a yönlendir + QR Göster + Profil Resmi + QR Tara + Yine De Göster + "Gönderi uygun değil olarak işaretlendi tarafından: " + Gönderi bulunamadı + Kanal Resmi + Referanslı etkinlik bulunamadı + Mesaj deşifre edilemedi + Grup Resmi + Müstehcen İçerik + İstenmeyen + Taklit Etme + İllegal Davranış + Bilinmeyen + Relay İkonu + Bilinmeyen Yazar + Yazıyı Kopyala + Kullanıcı PubKey\'ini Kopyala + Not ID\'sini Kopyala + Yayın + Blokla Ve Engelle + İstenmeyen Raporla + Taklit Etme Raporla + Müstehcen İçerik Raporla + İllegal Davranış Raporla + Yanıtlamak için bir Gizli Anahtarla giriş yap + Gönderileri desteklemek için bir Gizli Anahtarla giriş yap + Gönderileri beğenmek için bir Gizli Anahtarla giriş yap + Zap miktarı belirlenmedi. Değiştirmek için uzun basılı tutun + Zap göndermek için bir Gizli Anahtarla giriş yap + Zapler + Görüntülenme sayısı + Destekle + desteklendi + Alıntı + Satlarda Yeni Miktar + Ekle + yanıtlanıyor + ve + kanalda + Profil Bayrağı + Takip edilenler + Takipçiler + Profil + Güvenlik Filtreleri + Çıkış Yap + Daha Fazla Göster + Lightning Faturası + Öde + Yıldırım Bahşişleri + Alıcıya Mesaj + Çok teşekkürler! + Satlarda Miktar + Satlar Gönder + Asla çevirme + Yeni Kanal + Kanal Adı + Benim Harika Grubum + Resim URL\'si + Açıklama + Hakkımızda + Aklında ne var? + Gönder + Kaydet + Oluştur + Vazgeç + Resim yüklenemedi + Relay Adresi + Gönderiler + Hatalar + Ana Akış + Gizli Mesaj Akışı + Açık Mesaj Akışı + Küresel Akış + Akış Ara + Relay Ekle + Görüntü Adı + Benim görüntü adım + Kullanıcı Adı + Benim kullanıcı adım + Hakkımda + Avatar URL\'i + Bayrakk URL\'i + Website URL\'si + LN Adresi + LN URL\'si (tarihi geçmiş) + Resim galeriye kaydedildi + Resim galeriye kaydedilemedi + Resim Yükle + Yükleniyor... + Kullanıcının sats alabileceği bir lightning adresi kurulu değil + buraya yanıtlayın... + Paylaşmak için Not ID\'sini panoya kopyalar + Kanal ID\'sini (Not) panoya kopyala + Kanal Üstveri\'sini Editle + Katıl + Bilinen + Yeni İstekler + Engellenmiş Kullanıcılar + Yeni Threadler + Diyaloglar + Notlar + Yanıtlar + Takipler + Raporlar + Daha Fazla Seçenek + Relaylar + Website + Lİghtning Adresi + Yedekleme için Nsec ID\'yi (sizin şifreniz) panoya kopyalar + Gizli Anahtarı Panoya Kopyala + Paylaşmak için açık anahtarı panoya kopyalar + Açık Anahtarı (NPub) Panoya Kopyala + Direkt Mesaj Gönder + Kullanıcının Üstverisini Düzenler + Takip Et + Engelleme + Kullanıcı ID\'sini Kopyala + Kullanıcıyı Engelleme + npub, hex, kullanıcı adı + Temizle + Uygulama Logosu + nsec / npub / hex gizli anahtarı + Şifreyi Göster + Şifreyi Gizle + Geçersiz Anahtar + Kabul ediyorum + kullanım şartlarını + Kullanım şartlarını kabul etmeniz gerekiyor + Anahtar gerekiyor + Giriş Yap + Yeni anahtar oluştur + Akış yükleniyor + "Yanıtları yüklemekte hata: " + Tekrar dene + Akış boş + Yenile + oluşturuldu + şu açıklamayla + ve fotoğrafla + chat ismini şuna değiştirdi + açıklamayı + fotoğrafı + Ayrıl + Takip Etme + Kanal oluşturuldu + Kanal Bilgisi şuna değiştirildi + Herkese Açık Chat + alınan gönderiler + Kaldır + satlar + Otomatik + şundan çevirildi + şuna + Şunda göster + ilk + Her zaman şuna çevir + NIP-05 + LNURL + asla + şimdi + h + m + d + Çıplaklık + Küfür / Nefret dolu konuşma + Nefret dolu konuşmayı raporla + Çıplaklık / Cinselliği raporla + diğerleri + Tüm bilinenleri okundu olarak işaretle + Tüm yenileri okundu olarak işaretle + Tümünü okundu olarak işaretle + Hata + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 019803de8e..bf1bebabe2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - + Amethyst Amethyst Debug Point to the QR Code @@ -20,7 +20,7 @@ Relay Icon Unknown Author Copy Text - Copy User PubKey + Copy Author @npub Copy Note ID Broadcast @@ -177,7 +177,7 @@ Mark all Known as read Mark all New as read Mark all as read - + ## Key Backup and Safety Tips \n\nYour account is secured by a secret key. The key is long random string starting with **nsec1**. Anyone who has access to your secret key can publish content using your identity. \n\n- Do **not** put your secret key in any website or software you do not trust. @@ -187,9 +187,24 @@ Secret key (nsec) copied to clipboard Copy my secret key Authentication failed - + Error "Created by %1$s" "Badge award image for %1$s" You Received a new Badge Award Badge award granted to - \ No newline at end of file + Copied note text to clipboard + Copied author’s @npub to clipboard + Copied note ID (@note1) to clipboard + Select Text + Select + Share Browser Link + Share + Mention + Quote + Copy + Delete + Unfollow + Follow + 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. + diff --git a/app/src/test/java/com/vitorpamplona/amethyst/KeyParseTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/KeyParseTest.kt index f9053cabf8..7d3a1171b9 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/KeyParseTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/KeyParseTest.kt @@ -10,84 +10,83 @@ import org.junit.Test * See [testing documentation](http://d.android.com/tools/testing). */ class KeyParseTest { - @Test - fun keyParseTestNote() { - val result = parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn") - assertEquals("note", result?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) - assertEquals("", result?.restOfWord) - } + @Test + fun keyParseTestNote() { + val result = parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn") + assertEquals("note", result?.key?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) + assertEquals("", result?.restOfWord) + } - @Test - fun keyParseTestPub() { - val result = parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - assertEquals("npub", result?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) - assertEquals("", result?.restOfWord) - } + @Test + fun keyParseTestPub() { + val result = parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") + assertEquals("npub", result?.key?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) + assertEquals("", result?.restOfWord) + } - @Test - fun keyParseTestNoteWithExtraChars() { - val result = parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals("note", result?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestNoteWithExtraChars() { + val result = parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") + assertEquals("note", result?.key?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestPubWithExtraChars() { - val result = parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals("npub", result?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestPubWithExtraChars() { + val result = parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") + assertEquals("npub", result?.key?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestNoteWithExtraCharsAndAt() { - val result = parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals("note", result?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestNoteWithExtraCharsAndAt() { + val result = parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") + assertEquals("note", result?.key?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestPubWithExtraCharsAndAt() { - val result = parseDirtyWordForKey("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals("npub", result?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestPubWithExtraCharsAndAt() { + val result = parseDirtyWordForKey("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") + assertEquals("npub", result?.key?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestNoteWithExtraCharsAndNostrPrefix() { - val result = parseDirtyWordForKey("nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals("note", result?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestNoteWithExtraCharsAndNostrPrefix() { + val result = parseDirtyWordForKey("nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") + assertEquals("note", result?.key?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestPubWithExtraCharsAndNostrPrefix() { - val result = parseDirtyWordForKey("nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals("npub", result?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestPubWithExtraCharsAndNostrPrefix() { + val result = parseDirtyWordForKey("nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") + assertEquals("npub", result?.key?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) + assertEquals(",", result?.restOfWord) + } + @Test + fun keyParseTestUppercaseNoteWithExtraCharsAndNostrPrefix() { + val result = parseDirtyWordForKey("Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") + assertEquals("note", result?.key?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestUppercaseNoteWithExtraCharsAndNostrPrefix() { - val result = parseDirtyWordForKey("Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals("note", result?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) - assertEquals(",", result?.restOfWord) - } - - @Test - fun keyParseTestUppercasePubWithExtraCharsAndNostrPrefix() { - val result = parseDirtyWordForKey("nOstr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals("npub", result?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) - assertEquals(",", result?.restOfWord) - } -} \ No newline at end of file + @Test + fun keyParseTestUppercasePubWithExtraCharsAndNostrPrefix() { + val result = parseDirtyWordForKey("nOstr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") + assertEquals("npub", result?.key?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) + assertEquals(",", result?.restOfWord) + } +} diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt index 77d6a96c09..0146d40569 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt @@ -1,32 +1,56 @@ package com.vitorpamplona.amethyst -import com.vitorpamplona.amethyst.service.Nip19 import com.vitorpamplona.amethyst.service.model.ATag +import com.vitorpamplona.amethyst.service.nip19.Nip19 import org.junit.Assert.assertEquals import org.junit.Test class NIP19ParserTest { - @Test - fun nAddrParser() { - val result = Nip19().uriToRoute("nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus") - assertEquals("30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", result?.hex) - } + @Test + fun nAddrParser() { + val result = Nip19.uriToRoute("nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus") + assertEquals("30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", result?.hex) + } - @Test - fun nAddrParser2() { - val result = Nip19().uriToRoute("nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8") - assertEquals("30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", result?.hex) - } + @Test + fun nAddrParser2() { + val result = Nip19.uriToRoute("nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8") + assertEquals("30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", result?.hex) + } - @Test - fun nAddrFormatter() { - val address = ATag(30023, "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", "" ) - assertEquals("naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", address.toNAddr()) - } + @Test + fun nAddrParse3() { + val result = Nip19.uriToRoute("naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38") + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals("30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", result?.hex) + assertEquals("wss://relay.damus.io", result?.relay) + } - @Test - fun nAddrFormatter2() { - val address = ATag(30023, "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c", "guide-wireguard" ) - assertEquals("naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", address.toNAddr()) - } -} \ No newline at end of file + @Test + fun nAddrATagParse3() { + val address = ATag.parse("30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", "wss://relay.damus.io") + assertEquals(30023, address?.kind) + assertEquals("d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", address?.pubKeyHex) + assertEquals("89de7920", address?.dTag) + assertEquals("wss://relay.damus.io", address?.relay) + assertEquals("naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", address?.toNAddr()) + } + + @Test + fun nAddrFormatter() { + val address = ATag(30023, "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", "", null) + assertEquals("naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", address.toNAddr()) + } + + @Test + fun nAddrFormatter2() { + val address = ATag(30023, "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c", "guide-wireguard", null) + assertEquals("naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", address.toNAddr()) + } + + @Test + fun nAddrFormatter3() { + val address = ATag(30023, "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", "89de7920", "wss://relay.damus.io") + assertEquals("naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", address.toNAddr()) + } +} diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip19Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip19Test.kt index 53de039708..0c9c3aa0a5 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip19Test.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip19Test.kt @@ -1,109 +1,82 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.service.nip19.Nip19 import org.junit.Assert import org.junit.Ignore import org.junit.Test class Nip19Test { + @Test() + fun uri_to_route_null() { + val actual = Nip19.uriToRoute(null) - private val nip19 = Nip19(); + Assert.assertEquals(null, actual) + } - @Test(expected = IllegalArgumentException::class) - fun to_int_32_length_smaller_than_4() { - toInt32(byteArrayOfInts(1, 2, 3)) - } + @Test() + fun uri_to_route_unknown() { + val actual = Nip19.uriToRoute("nostr:unknown") - @Test(expected = IllegalArgumentException::class) - fun to_int_32_length_bigger_than_4() { - toInt32(byteArrayOfInts(1, 2, 3, 4, 5)) - } + Assert.assertEquals(null, actual) + } - @Test() - fun to_int_32_length_4() { - val actual = toInt32(byteArrayOfInts(1, 2, 3, 4)) + @Test() + fun uri_to_route_npub() { + val actual = + Nip19.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") - Assert.assertEquals(16909060, actual) - } + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals( + "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", + actual?.hex + ) + } - @Ignore("Test not implemented yet") - @Test() - fun parse_TLV() { - // TODO: I don't know how to test this (?) - } + @Test() + fun uri_to_route_note() { + val actual = + Nip19.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv") - @Test() - fun uri_to_route_null() { - val actual = nip19.uriToRoute(null) + Assert.assertEquals(Nip19.Type.NOTE, actual?.type) + Assert.assertEquals( + "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", + actual?.hex + ) + } - Assert.assertEquals(null, actual) - } + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_nprofile() { + val actual = Nip19.uriToRoute("nostr:nprofile") - @Test() - fun uri_to_route_unknown() { - val actual = nip19.uriToRoute("nostr:unknown") + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - Assert.assertEquals(null, actual) - } + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_nevent() { + val actual = Nip19.uriToRoute("nostr:nevent") - @Test() - fun uri_to_route_npub() { - val actual = - nip19.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals( - "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", - actual?.hex - ) - } + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_nrelay() { + val actual = Nip19.uriToRoute("nostr:nrelay") - @Test() - fun uri_to_route_note() { - val actual = - nip19.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv") + Assert.assertEquals(Nip19.Type.RELAY, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - Assert.assertEquals(Nip19.Type.NOTE, actual?.type) - Assert.assertEquals( - "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", - actual?.hex - ) - } + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_naddr() { + val actual = Nip19.uriToRoute("nostr:naddr") - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nprofile() { - val actual = nip19.uriToRoute("nostr:nprofile") - - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals("*", actual?.hex) - } - - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nevent() { - val actual = nip19.uriToRoute("nostr:nevent") - - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals("*", actual?.hex) - } - - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nrelay() { - val actual = nip19.uriToRoute("nostr:nrelay") - - Assert.assertEquals(Nip19.Type.RELAY, actual?.type) - Assert.assertEquals("*", actual?.hex) - } - - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_naddr() { - val actual = nip19.uriToRoute("nostr:naddr") - - Assert.assertEquals(Nip19.Type.ADDRESS, actual?.type) - Assert.assertEquals("*", actual?.hex) - } - - private fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } + Assert.assertEquals(Nip19.Type.ADDRESS, actual?.type) + Assert.assertEquals("*", actual?.hex) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/TlvTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/TlvTest.kt new file mode 100644 index 0000000000..9f9087036f --- /dev/null +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/TlvTest.kt @@ -0,0 +1,35 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.service.nip19.Tlv +import org.junit.Assert +import org.junit.Ignore +import org.junit.Test + +class TlvTest { + + @Test(expected = IllegalArgumentException::class) + fun to_int_32_length_smaller_than_4() { + Tlv.toInt32(byteArrayOfInts(1, 2, 3)) + } + + @Test(expected = IllegalArgumentException::class) + fun to_int_32_length_bigger_than_4() { + Tlv.toInt32(byteArrayOfInts(1, 2, 3, 4, 5)) + } + + @Test() + fun to_int_32_length_4() { + val actual = Tlv.toInt32(byteArrayOfInts(1, 2, 3, 4)) + + Assert.assertEquals(16909060, actual) + } + + @Ignore("Test not implemented yet") + @Test() + fun parse_TLV() { + // TODO: I don't know how to test this (?) + } + + private fun byteArrayOfInts(vararg ints: Int) = + ByteArray(ints.size) { pos -> ints[pos].toByte() } +} diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt new file mode 100644 index 0000000000..8c84b0a83b --- /dev/null +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt @@ -0,0 +1,55 @@ +package com.vitorpamplona.amethyst.service.zaps + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.LnZapEventInterface +import com.vitorpamplona.amethyst.service.model.zaps.UserZaps +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert +import org.junit.Test +import java.math.BigDecimal + +class UserZapsTest { + @Test + fun nothing() { + Assert.assertEquals(1, 1) + } + + @Test + fun user_without_zaps() { + val actual = UserZaps.forProfileFeed(zaps = null) + + Assert.assertEquals(emptyList>(), actual) + } + + @Test + fun avoid_duplicates_with_same_zap_request() { + val zapRequest = mockk() + + val zaps: Map = mapOf( + zapRequest to mockZapNoteWith("user-1", amount = 100), + zapRequest to mockZapNoteWith("user-1", amount = 200) + ) + + val actual = UserZaps.forProfileFeed(zaps) + + Assert.assertEquals(1, actual.count()) + Assert.assertEquals(zapRequest, actual.first().first) + Assert.assertEquals( + BigDecimal(200), + (actual.first().second.event as LnZapEventInterface).amount() + ) + } + + private fun mockZapNoteWith(pubkey: HexKey, amount: Int): Note { + val lnZapEvent = mockk() + every { lnZapEvent.amount() } returns amount.toBigDecimal() + every { lnZapEvent.pubKey() } returns pubkey + + val zapNote = mockk() + every { zapNote.event } returns lnZapEvent + + return zapNote + } +} diff --git a/build.gradle b/build.gradle index 2e35222a9c..6c6032c6ad 100644 --- a/build.gradle +++ b/build.gradle @@ -13,4 +13,11 @@ plugins { id 'com.android.library' version '7.4.1' apply false id 'org.jetbrains.kotlin.android' version '1.8.10' apply false id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false -} \ No newline at end of file +} + +task installGitHook(type: Copy) { + from new File(rootProject.rootDir, 'pre-commit') + into { new File(rootProject.rootDir, '.git/hooks') } + fileMode 0777 +} +tasks.getByPath(':app:preBuild').dependsOn installGitHook \ No newline at end of file diff --git a/pre-commit b/pre-commit new file mode 100755 index 0000000000..c913761ba4 --- /dev/null +++ b/pre-commit @@ -0,0 +1,23 @@ +#!/bin/bash + +GREEN='\033[0;32m' +NO_COLOR='\033[0m' + +echo "*********************************************************" +echo "Running git pre-commit hook. Running Static analysis... " +echo "*********************************************************" + +./gradlew ktlintCheck + +status=$? + +if [ "$status" = 0 ] ; then + echo "Static analysis found no problems." + exit 0 +else + echo "*********************************************************" + echo 1>&2 "Static analysis found violations it could not fix." + printf "Run ${GREEN}./gradlew ktlintFormat${NO_COLOR} to fix formatting related issues...\n" + echo "*********************************************************" + exit 1 +fi