diff --git a/.idea/misc.xml b/.idea/misc.xml index bdd92780c..4d86ddb47 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/EventSigCheck.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/EventSigCheck.kt index 2b6adfc77..944e4239c 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 49cbedde4..3cf582051 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 706bae212..dd56ea31c 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -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 - ) + var 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" + var 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 602e05a09..9ab8a8684 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 950e24d60..f0dfe6331 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,7 @@ 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}" } + val LAST_READ: (String) -> String = { route -> "last_read_route_$route" } } private val encryptedPreferences = EncryptedStorage().preferences(context) @@ -83,10 +83,10 @@ 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() } if (pubKey != null) { @@ -118,5 +118,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 90018df67..0173fe3bd 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 2f23c5fbc..a4d95abd4 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 cd60e2916..399c78f99 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -16,63 +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() + 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 fc341428f..4d01c8301 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 18ec62fcd..3948479f5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -7,17 +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.Locale -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -27,575 +30,583 @@ 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 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().latestContactList?.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().latestContactList?.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 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)), - contactList.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 }, - contactList.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?.unverifiedFollowKeySet().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().latestContactList?.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 followingKeySet(): Set { - return userProfile().latestContactList?.verifiedFollowKeySet ?: emptySet() - } - - 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( followingKeySet() ) < 5 - } - - fun isAcceptableDirect(note: Note): Boolean { - return note.reportsBy( userProfile() ).isEmpty() // if user has not reported this post - && note.countReportAuthorsBy( followingKeySet() ) < 5 // if it has 5 reports by reliable users - } - - fun isFollowing(user: User): Boolean { - return user.pubkeyHex in followingKeySet() - } - - 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().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() - - 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() - } - - 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.pubkeyHex !in followingKeySet()) { - transientHiddenUsers = transientHiddenUsers + it.pubkeyHex - } - } + 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 + } + + 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().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!) + } + + 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().latestContactList?.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)), + contactList.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 }, + contactList.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?.unverifiedFollowKeySet().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().latestContactList?.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 followingKeySet(): Set { + return userProfile().latestContactList?.verifiedFollowKeySet ?: emptySet() + } + + 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(followingKeySet()) < 5 + } + + fun isAcceptableDirect(note: Note): Boolean { + return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post + note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users + } + + fun isFollowing(user: User): Boolean { + return user.pubkeyHex in followingKeySet() + } + + 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().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() + + 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() + } + + 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.pubkeyHex !in followingKeySet()) { + 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) 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 a31a908e3..dfed1554c 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 882b46efa..b5f23668e 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 c8fb14180..eaac03ef6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt @@ -14,70 +14,68 @@ 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) 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)) { + 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) + } + } 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 c01869a43..614ec3c50 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -13,27 +13,20 @@ 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.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.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 @@ -42,705 +35,705 @@ 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 (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 replyTo = event.replyToWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, 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) - - // 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 replyTo = event.replyToWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, replyTo) - - author.addNote(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()) - - 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, 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, awardDefinition) - - // Counts the replies - awardees.forEach { - it.addBadgeAward(note) - } - - // Replies of an Badge Definition are Award Events - awardDefinition.forEach { - it.addReply(note) - } - - refreshObservers() - } - - fun consume(event: RecommendRelayEvent) { - //Log.d("RR", event.toJson()) - } - - fun consume(event: ContactListEvent) { - val user = getOrCreateUser(event.pubKey) - val follows = event.unverifiedFollowKeySet() - - if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !follows.isNullOrEmpty()) { - // Saves relay list only if it's a user that is currently been seen - user.updateContactList(event) - - 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 - - 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) } - - note.loadEvent(event, author, 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 - val mentions = deleteNote.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) } - - mentions?.forEach { user -> - user.removeReport(deleteNote) + 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 (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 replyTo = event.replyToWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, 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) // Counts the replies - deleteNote.replyTo?.forEach { masterNote -> - masterNote.removeReply(deleteNote) - masterNote.removeBoost(deleteNote) - masterNote.removeReaction(deleteNote) - masterNote.removeZap(deleteNote) - masterNote.removeReport(deleteNote) + replyTo.forEach { + it.addReply(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 repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, repliesTo) - - // Prepares user's profile view. - author.addNote(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, repliesTo) - - //Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - 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, 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()) - 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 + fun consume(event: LongTextNoteEvent, relay: Relay?) { + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) - // 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) + 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 replyTo = event.replyToWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, replyTo) + + author.addNote(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()) + + 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, replyTo) + + author.updateAcceptedBadges(note) + + refreshObservers() + } + } + + fun consume(event: BadgeAwardEvent) { val note = getOrCreateNote(event.id) - oldChannel.addNote(note) - note.loadEvent(event, author, 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() + // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - if (channelId.isNullOrBlank()) return + val author = getOrCreateUser(event.pubKey) + val awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) } + val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) } - 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, replyTo) - - //Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}") - - // 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, 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)}") - - 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, repliesTo) - - //Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - 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 - val mentions = it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) } + note.loadEvent(event, author, awardDefinition) // Counts the replies - it.replyTo?.forEach { replyingNote -> - it.removeReply(it) + awardees.forEach { + it.addBadgeAward(note) } - } - println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}") - } - } - - 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) - - // 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)) { - - // 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(50) - refresh() - } finally { - withContext(NonCancellable) { - handlerWaiting.set(false) + // Replies of an Badge Definition are Award Events + awardDefinition.forEach { + it.addReply(note) } - } + + refreshObservers() } - } - private fun refresh() { - postValue(LocalCacheState(cache)) - } + fun consume(event: RecommendRelayEvent) { + // Log.d("RR", event.toJson()) + } + + fun consume(event: ContactListEvent) { + val user = getOrCreateUser(event.pubKey) + val follows = event.unverifiedFollowKeySet() + + if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !follows.isNullOrEmpty()) { + // Saves relay list only if it's a user that is currently been seen + user.updateContactList(event) + + 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 + + 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) } + + note.loadEvent(event, author, 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 + val mentions = deleteNote.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) } + + mentions?.forEach { user -> + user.removeReport(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 repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, repliesTo) + + // Prepares user's profile view. + author.addNote(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, repliesTo) + + // Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + 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, 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()) + + 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()) + + 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, replyTo) + + // Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}") + + // 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, 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)}") + + 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, repliesTo) + + // Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + 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 + val mentions = it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) } + + // Counts the replies + it.replyTo?.forEach { replyingNote -> + it.removeReply(it) + } + } + + println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}") + } + } + + 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) + + // 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 LocalCacheState(val cache: LocalCache) { +class LocalCacheLiveData(val cache: LocalCache) : LiveData(LocalCacheState(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(50) + refresh() + } finally { + withContext(NonCancellable) { + handlerWaiting.set(false) + } + } + } + } + + private fun refresh() { + postValue(LocalCacheState(cache)) + } } + +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 250c275e1..b9df1a584 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -6,13 +6,6 @@ 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 @@ -20,11 +13,17 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +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.toTag()) { +class AddressableNote(val address: ATag) : Note(address.toTag()) { override fun idNote() = address.toNAddr() override fun idDisplayNote() = idNote().toShortenHex() override fun address() = address @@ -61,9 +60,9 @@ 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) } } @@ -135,7 +134,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() @@ -154,7 +153,6 @@ open class Note(val idHex: String) { } } - fun addBoost(note: Note) { if (note !in boosts) { boosts = boosts + note @@ -238,11 +236,13 @@ 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 { @@ -255,7 +255,6 @@ open class Note(val idHex: String) { returningList.add(tag[1]) } } catch (e: Exception) { - } } return returningList @@ -273,17 +272,16 @@ open class Note(val idHex: String) { } } } 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 { @@ -304,7 +302,7 @@ open class Note(val idHex: String) { 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 { @@ -327,7 +325,6 @@ open class Note(val idHex: String) { } } - class NoteLiveSet(u: Note) { // Observers line up here. val metadata: NoteLiveData = NoteLiveData(u) @@ -340,17 +337,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() @@ -382,7 +379,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 ddaf9f728..1f11bff54 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 6c3bde56c..8b7617784 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.contains(":")) { - val aTag = ATag.parse(noteId, null) - 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) + } + } } - } } 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 02b9555f6..e49cdf630 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 e5f91df3e..cc9d4cb01 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\\/(.*)") @@ -134,7 +134,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() @@ -284,7 +284,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 } @@ -306,8 +306,6 @@ class User(val pubkeyHex: String) { liveSet = null } } - - } class UserLiveSet(u: User) { @@ -322,18 +320,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 @@ -341,7 +339,6 @@ data class RelayInfo ( data class Chatroom(var roomMessages: Set) - class UserMetadata { var name: String? = null var username: String? = null @@ -365,16 +362,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() @@ -411,4 +408,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 52b42ee56..28bb298f4 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 index 00db14164..73b0a56bc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt @@ -7,132 +7,132 @@ import java.nio.ByteOrder class Nip19 { - enum class Type { - USER, NOTE, RELAY, ADDRESS - } - - data class Return(val type: Type, val hex: String, val relay: 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}") + enum class Type { + USER, NOTE, RELAY, ADDRESS } - return null - } + data class Return(val type: Type, val hex: String, val relay: String?) - private fun npub(bytes: ByteArray): Return { - return Return(Type.USER, bytes.toHexKey(), null) - } + fun uriToRoute(uri: String?): Return? { + try { + val key = uri?.removePrefix("nostr:") ?: return null - private fun note(bytes: ByteArray): Return { - return Return(Type.NOTE, bytes.toHexKey(), 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}") + } - private fun nprofile(bytes: ByteArray): Return? { - val tlv = parseTLV(bytes) + return null + } - val hex = tlv.get(NIP19TLVTypes.SPECIAL.id) - ?.get(0) - ?.toHexKey() ?: return null + private fun npub(bytes: ByteArray): Return { + return Return(Type.USER, bytes.toHexKey(), null) + } - val relay = tlv.get(NIP19TLVTypes.RELAY.id) - ?.get(0) - ?.toString(Charsets.UTF_8) + private fun note(bytes: ByteArray): Return { + return Return(Type.NOTE, bytes.toHexKey(), null) + } - return Return(Type.USER, hex, relay) - } + private fun nprofile(bytes: ByteArray): Return? { + val tlv = parseTLV(bytes) - private fun nevent(bytes: ByteArray): Return? { - val tlv = parseTLV(bytes) + val hex = tlv.get(NIP19TLVTypes.SPECIAL.id) + ?.get(0) + ?.toHexKey() ?: return null - val hex = tlv.get(NIP19TLVTypes.SPECIAL.id) - ?.get(0) - ?.toHexKey() ?: return null + val relay = tlv.get(NIP19TLVTypes.RELAY.id) + ?.get(0) + ?.toString(Charsets.UTF_8) - val relay = tlv.get(NIP19TLVTypes.RELAY.id) - ?.get(0) - ?.toString(Charsets.UTF_8) + return Return(Type.USER, hex, relay) + } - return Return(Type.USER, hex, relay) - } + private fun nevent(bytes: ByteArray): Return? { + val tlv = parseTLV(bytes) - private fun nrelay(bytes: ByteArray): Return? { - val relayUrl = parseTLV(bytes) - .get(NIP19TLVTypes.SPECIAL.id) - ?.get(0) - ?.toString(Charsets.UTF_8) ?: return null + val hex = tlv.get(NIP19TLVTypes.SPECIAL.id) + ?.get(0) + ?.toHexKey() ?: return null - return Return(Type.RELAY, relayUrl, null) - } + val relay = tlv.get(NIP19TLVTypes.RELAY.id) + ?.get(0) + ?.toString(Charsets.UTF_8) - private fun naddr(bytes: ByteArray): Return? { - val tlv = parseTLV(bytes) + return Return(Type.USER, hex, relay) + } - val d = tlv.get(NIP19TLVTypes.SPECIAL.id) - ?.get(0) - ?.toString(Charsets.UTF_8) ?: return null + private fun nrelay(bytes: ByteArray): Return? { + val relayUrl = parseTLV(bytes) + .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) + return Return(Type.RELAY, relayUrl, null) + } - val author = tlv.get(NIP19TLVTypes.AUTHOR.id) - ?.get(0) - ?.toHexKey() + private fun naddr(bytes: ByteArray): Return? { + val tlv = parseTLV(bytes) - val kind = tlv.get(NIP19TLVTypes.KIND.id) - ?.get(0) - ?.let { toInt32(it) } + val d = tlv.get(NIP19TLVTypes.SPECIAL.id) + ?.get(0) + ?.toString(Charsets.UTF_8) ?: return null - return Return(Type.ADDRESS, "$kind:$author:$d", relay) - } + 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", relay) + } } // Classes should start with an uppercase letter in kotlin enum class NIP19TLVTypes(val id: Byte) { - SPECIAL(0), - RELAY(1), - AUTHOR(2), - KIND(3); + 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 + 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 + 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() + if (!result.containsKey(t)) { + result[t] = mutableListOf() + } + result[t]?.add(v) } - result[t]?.add(v) - } - return result + 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 1ff1b4a41..fac4279b9 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 72c2b5a4a..2b3845e8c 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 4bb33fef9..b94abf0bb 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 850e90872..d3d083355 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 d248068dd..961e850a8 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 6b09e522e..4a8830dea 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 49c231442..d8b01a019 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.followingKeySet() - - val followKeys = follows.map { - it.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.followingKeySet() - override fun updateChannelFilters() { - followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null } - } -} \ No newline at end of file + val followKeys = follows.map { + it.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 03cd8eb20..63d601b16 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 4b4df7907..037e03dd7 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 d40378425..440744fe8 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 328344a68..3685ed9fd 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 52383ac53..e16f9e70f 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 cace13279..39c85a97b 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), - authors = 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 be22a9c33..aa3bc4f32 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 7d3fe774d..1a2371805 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 18f730270..c2864d57b 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 ebdd47928..f54a4c886 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 @@ -23,8 +23,9 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela var fullArray = byteArrayOf(NIP19TLVTypes.SPECIAL.id, dTag.size.toByte()) + dTag - if (relay != null) + if (relay != null) { fullArray = fullArray + byteArrayOf(NIP19TLVTypes.RELAY.id, relay.size.toByte()) + relay + } fullArray = fullArray + byteArrayOf(NIP19TLVTypes.AUTHOR.id, author.size.toByte()) + author + @@ -39,19 +40,20 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela } fun parse(address: String, relay: String?): ATag? { - return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) + return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) { parseNAddr(address) - else + } 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], relay) + 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 } } @@ -67,16 +69,16 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela val author = tlv.get(NIP19TLVTypes.AUTHOR.id)?.get(0)?.toHexKey() val kind = tlv.get(NIP19TLVTypes.KIND.id)?.get(0)?.let { toInt32(it) } - if (kind != null && author != null) + 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 c27b6b0b2..6269f63c0 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,7 +9,7 @@ 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 { val aTagValue = it.getOrNull(1) 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 e084f1c58..e7c974812 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,7 +9,7 @@ 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(), null) 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 d13925222..4995c413b 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,7 +9,7 @@ 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 { val aTagValue = it.getOrNull(1) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt index ea7fbb5b2..b82053692 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt @@ -4,45 +4,43 @@ import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.tagSearch open class BaseTextNoteEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: List>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun mentions() = taggedUsers() - fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun mentions() = taggedUsers() + fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun findCitations(): Set { - var citations = mutableSetOf() - // Removes citations from replies: - val matcher = tagSearch.matcher(content) - while (matcher.find()) { - try { - val tag = matcher.group(1)?.let { tags[it.toInt()] } - if (tag != null && tag[0] == "e") { - citations.add(tag[1]) + fun findCitations(): Set { + var citations = mutableSetOf() + // Removes citations from replies: + val matcher = tagSearch.matcher(content) + while (matcher.find()) { + try { + val tag = matcher.group(1)?.let { tags[it.toInt()] } + if (tag != null && tag[0] == "e") { + citations.add(tag[1]) + } + } catch (e: Exception) { + } } - } catch (e: Exception) { - - } + return citations } - return citations - } - fun replyToWithoutCitations(): List { - val repliesTo = replyTos() - if (repliesTo.isEmpty()) return repliesTo + fun replyToWithoutCitations(): List { + val repliesTo = replyTos() + if (repliesTo.isEmpty()) return repliesTo - val citations = findCitations() + val citations = findCitations() - return if (citations.isEmpty()) { - repliesTo - } else { - repliesTo.filter { it !in citations } + return if (citations.isEmpty()) { + repliesTo + } else { + repliesTo.filter { it !in citations } + } } - } - -} \ No newline at end of file +} 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 230bff7ae..f9205b61a 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(): ChannelData = 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(): ChannelData = 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 4f8b57f18..1a8481854 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 411a258d8..76a05c6cd 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 d84ea2ab8..6382e588c 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 6b12e96bf..79988b4de 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 6286a5d22..5c301b18e 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 @@ -5,8 +5,8 @@ import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.decodePublicKey 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?) @@ -17,7 +17,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) { // This function is only used by the user logged in // But it is used all the time. val verifiedFollowKeySet: Set by lazy { @@ -44,10 +44,11 @@ class ContactListEvent( } fun relays(): Map? = try { - if (content.isNotEmpty()) - gson.fromJson(content, object: TypeToken>() {}.type) as Map - else + if (content.isNotEmpty()) { + gson.fromJson(content, object : TypeToken>() {}.type) as Map + } else { null + } } catch (e: Exception) { Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e) null @@ -57,16 +58,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) @@ -75,4 +78,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 a29cd6261..4d7170f61 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 056284167..c6190f389 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 @@ -20,10 +20,10 @@ open class Event( val tags: List>, val content: String, val sig: HexKey -): EventInterface { +) : EventInterface { override fun id(): HexKey = id - override fun pubKey(): HexKey = pubKey + override fun pubKey(): HexKey = pubKey override fun createdAt(): Long = createdAt @@ -41,7 +41,6 @@ open class Event( override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex } - /** * Checks if the ID is correct and then if the pubKey's secret key signed the event. */ @@ -50,8 +49,9 @@ open class Event( throw Exception( """|Unexpected ID. | Event: ${toJson()} - | Actual ID: ${id} - | Generated: ${generateId()}""".trimIndent() + | Actual ID: $id + | Generated: ${generateId()} + """.trimIndent() ) } if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) { @@ -113,15 +113,20 @@ 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) } 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 index be17a058f..c30153767 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt @@ -3,25 +3,25 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey interface EventInterface { - fun id(): HexKey + fun id(): HexKey - fun pubKey(): HexKey + fun pubKey(): HexKey - fun createdAt(): Long + fun createdAt(): Long - fun kind(): Int + fun kind(): Int - fun tags(): List> + fun tags(): List> - fun content(): String + fun content(): String - fun sig(): HexKey + fun sig(): HexKey - fun toJson(): String + fun toJson(): String - fun checkSignature() + fun checkSignature() - fun hasValidSignature(): Boolean + fun hasValidSignature(): Boolean - fun isTaggedUser(loggedInUser: String): Boolean + fun isTaggedUser(loggedInUser: String): 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 688e34aa0..b8845b8d5 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 @@ -7,60 +7,60 @@ 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 -): LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { - override fun zappedPost() = tags - .filter { it.firstOrNull() == "e" } - .mapNotNull { it.getOrNull(1) } + override fun zappedPost() = tags + .filter { it.firstOrNull() == "e" } + .mapNotNull { it.getOrNull(1) } - override fun zappedAuthor() = tags - .filter { it.firstOrNull() == "p" } - .mapNotNull { it.getOrNull(1) } + override fun zappedAuthor() = tags + .filter { it.firstOrNull() == "p" } + .mapNotNull { it.getOrNull(1) } - override fun taggedAddresses(): List = tags - .filter { it.firstOrNull() == "a" } - .mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) + override fun taggedAddresses(): List = 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 + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } + + override fun amount(): BigDecimal? { + return lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } } - override fun amount(): BigDecimal? { - return lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } - } - - // 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) + // Keeps this as a field because it's a heavier function used everywhere. + val amount by lazy { + lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } } - } 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() + 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 description(): String? = tags - .filter { it.firstOrNull() == "description" } - .mapNotNull { it.getOrNull(1) } - .firstOrNull() + private fun lnInvoice(): String? = tags + .filter { it.firstOrNull() == "bolt11" } + .mapNotNull { it.getOrNull(1) } + .firstOrNull() - companion object { - const val kind = 9735 - } + 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 index 144edd730..cc95d34b3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt @@ -2,15 +2,15 @@ package com.vitorpamplona.amethyst.service.model import java.math.BigDecimal -interface LnZapEventInterface: EventInterface { +interface LnZapEventInterface : EventInterface { - fun zappedPost(): List + fun zappedPost(): List - fun zappedAuthor(): List + fun zappedAuthor(): List - fun taggedAddresses(): List + fun taggedAddresses(): List - fun amount(): BigDecimal? + fun amount(): BigDecimal? - fun containedPost(): Event? + 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 4d7f3f911..75ce41ca2 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,59 +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 { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) +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) - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - - 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()) + 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()) + } } - } } /* 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 e9e966934..46f136248 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,7 +12,7 @@ class LongTextNoteEvent( tags: List>, content: String, sig: HexKey -): BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { +) : BaseTextNoteEvent(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(), null) 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 bd4c4c6a6..312d3ca43 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 d0f5111f1..89e1d98e6 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 5525ff7f5..75c3eee5b 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,50 +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 { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) + 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) - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - - 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) + if (aTagValue != null) ATag.parse(aTagValue, relay) else null } - fun createLike(originalNote: EventInterface, 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: 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()) - } - } } 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 a2ef2eaec..117931b99 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 8ca58d7d8..4b2021c26 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,99 +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 { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) + companion object { + const val kind = 1984 - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } + fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent { + val content = "" - companion object { - const val kind = 1984 + val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase()) + val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase()) - fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent { - val content = "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + var tags: List> = listOf(reportPostTag, reportAuthorTag) - val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase()) - val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase()) + if (reportedPost is LongTextNoteEvent) { + tags = tags + listOf(listOf("a", reportedPost.address().toTag())) + } - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - var tags:List> = listOf(reportPostTag, 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()) + } - if (reportedPost is LongTextNoteEvent) { - tags = tags + listOf( listOf("a", reportedPost.address().toTag()) ) - } + fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent { + val content = "" - 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 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 aec19a407..ce13a6e5a 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,53 +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 { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - - 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()) + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } + + 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 9f1b156b7..0a67f8b1d 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,7 +12,7 @@ class TextNoteEvent( tags: List>, content: String, sig: HexKey -): BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { +) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { val aTagValue = it.getOrNull(1) @@ -41,4 +41,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 index 97747776c..a7e68b29c 100644 --- 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 @@ -4,13 +4,15 @@ 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() + 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> - } + 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/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index dff28ef7e..76d098c74 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,18 +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 com.vitorpamplona.amethyst.service.model.EventInterface +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: * 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 05ca9e387..5632e9da5 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 205ff76f0..54ea763c7 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 21cd7b0ef..3b132324f 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,7 +2,6 @@ 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 @@ -10,6 +9,7 @@ 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 @@ -19,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 @@ -77,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, @@ -113,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) { @@ -136,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)) } } } @@ -155,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 @@ -170,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) } } @@ -188,7 +190,7 @@ 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() } } @@ -201,24 +203,27 @@ class Relay( } } - 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 } @@ -232,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 99bddac0d..e1d5bb215 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,17 +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 -import com.vitorpamplona.amethyst.service.model.EventInterface /** * 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) @@ -30,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) } @@ -59,7 +59,7 @@ object RelayPool: Relay.Listener { relays.forEach { it.send(signedEvent) } } - fun close(subscriptionId: String){ + fun close(subscriptionId: String) { relays.forEach { it.close(subscriptionId) } } @@ -123,7 +123,7 @@ 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)) } 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 6465fa9e6..fc1f01013 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 dcce6b393..d52a1d11c 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 cd622f88c..557beabdc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -23,66 +23,65 @@ 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 5e1b20990..386ab20bf 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 ccb6b49cb..e2fcf31b1 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 73d514810..bc8bb5a0f 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 95f7a481f..bd68bc7cb 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/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index f903635fa..842e2b685 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -36,13 +36,12 @@ import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import kotlinx.coroutines.delay -import com.vitorpamplona.amethyst.service.model.TextNoteEvent - @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -89,7 +88,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n }) UploadFromGallery( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingImage ) { postViewModel.upload(it, context) } @@ -99,8 +98,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n postViewModel.sendPost() onClose() }, - isActive = postViewModel.message.text.isNotBlank() - && !postViewModel.isUploadingImage + isActive = postViewModel.message.text.isNotBlank() && + !postViewModel.isUploadingImage ) } @@ -172,7 +171,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n ) ) } else if (videoExtension.matcher(removedParamsFromUrl) - .matches() + .matches() ) { VideoView(myUrlPreview) } else { @@ -184,7 +183,6 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } } - } val userSuggestions = postViewModel.userSuggestions @@ -197,7 +195,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n ) { itemsIndexed( userSuggestions, - key = { _, item -> item.pubkeyHex }) { index, item -> + key = { _, item -> item.pubkeyHex } + ) { index, item -> UserLine(item, account) { postViewModel.autocompleteWithUser(item) } 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 4ef8be924..daa0bb6c0 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 @@ -16,7 +16,7 @@ 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 @@ -186,4 +186,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 acc2ae430..86502b820 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 d6f0471f3..d4422143a 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 b0f032bcc..ac9bd2aea 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 370829413..a7857b953 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 9e7bc7c3f..e8dbca2cf 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 00e52dd37..ce0800e98 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 @@ -23,7 +23,7 @@ import com.vitorpamplona.amethyst.R @Composable fun UploadFromGallery( isUploading: Boolean, - onImageChosen: (Uri) -> Unit, + onImageChosen: (Uri) -> Unit ) { val cameraPermissionState = rememberPermissionState( @@ -40,8 +40,9 @@ fun UploadFromGallery( GallerySelect( onImageUri = { uri -> showGallerySelect = false - if (uri != null) + if (uri != null) { onImageChosen(uri) + } } ) } else { @@ -67,7 +68,7 @@ fun UploadFromGallery( Column { Button( onClick = { cameraPermissionState.launchPermissionRequest() }, - enabled = !isUploading, + enabled = !isUploading ) { if (!isUploading) { Text(stringResource(R.string.upload_image)) @@ -79,7 +80,6 @@ fun UploadFromGallery( } } - @Composable fun GallerySelect( onImageUri: (Uri?) -> Unit = { } @@ -99,4 +99,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 d4b4f2e5d..317162932 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 88a506244..be05eeca1 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 78e251a15..b16534b28 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 8afdec017..8e4dbcd33 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 54a4025af..23cd96fee 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 fbf0b079f..42ddcd1cd 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 04fc3d4a9..59e85d637 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 cbd22d9e7..353de6161 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 @@ -15,67 +15,67 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent @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 if (nip19.type == Nip19.Type.ADDRESS) { - val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex) + 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 (noteBase == null) { - Text( - "@${nip19.hex} " - ) + 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 { - 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) - ) + Text( + "@${nip19.hex} " + ) } - } 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 { - 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 7db7a2310..731f8bfa1 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 8cd690d40..2f36de982 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 70bdc114a..5e31fa293 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 fe765a63a..f94516400 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 e0e430c0a..63d96bb35 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 109a353e9..e658be1c2 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,20 @@ 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 +33,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.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import java.net.MalformedURLException @@ -61,240 +53,236 @@ 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("naddr1", true) - || word.startsWith("note1", true) - || word.startsWith("nprofile1", true) - || word.startsWith("nevent1", true) - || word.startsWith("@npub1", true) - || word.startsWith("@note1", true) - || word.startsWith("@addr1", true) - || word.startsWith("@nprofile1", true) - || word.startsWith("@nevent1", true) + return word.startsWith("nostr:", true) || + word.startsWith("npub1", true) || + word.startsWith("naddr1", true) || + word.startsWith("note1", true) || + word.startsWith("nprofile1", true) || + word.startsWith("nevent1", true) || + word.startsWith("@npub1", true) || + word.startsWith("@note1", true) || + word.startsWith("@addr1", true) || + word.startsWith("@nprofile1", true) || + word.startsWith("@nevent1", 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/TranslateableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt index 367c6f2fd..2c11013ca 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 d4adfc34d..2693f4a6f 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 52e039070..8b4802ad6 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 1a99d16c6..ee0089e56 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 0e07271ea..081bd720c 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 34cc1a4e1..b213c6399 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 7e705f0a6..b2196de82 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 388d6ce0c..6e19217dd 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 9ffad1205..9d77e1ac4 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 b8946f387..ddafd9e4a 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 7564c6077..d53b689d3 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 5907defab..e5f78f4e2 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 824a510bf..4899dbea0 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?.pubkeyHex !in account.followingKeySet()) - } - .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?.pubkeyHex !in account.followingKeySet()) + } + .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 156b9239d..7025190a6 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 c219ce612..449cbd733 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?.pubkeyHex in user.cachedFollowingKeySet() - // && 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?.pubkeyHex in user.cachedFollowingKeySet() && + // && 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 347d7ec06..22c3d69f7 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?.pubkeyHex in user.cachedFollowingKeySet() - // && 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?.pubkeyHex in user.cachedFollowingKeySet() && + // && 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?.pubkeyHex in user.cachedFollowingKeySet() - // && 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?.pubkeyHex in user.cachedFollowingKeySet() && + // && 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 4cb5ef65e..507feaeb2 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 @@ -5,40 +5,40 @@ import com.vitorpamplona.amethyst.model.LocalCache 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 { - val loggedInUser = account.userProfile().pubkeyHex - return LocalCache.notes.values - .filter { it.event?.isTaggedUser(loggedInUser) ?: false } - .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 { + val loggedInUser = account.userProfile().pubkeyHex + return LocalCache.notes.values + .filter { it.event?.isTaggedUser(loggedInUser) ?: false } + .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 3d49eee10..e82baf0f0 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 4e7abefcc..27ac42c82 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 36f47d558..2fe984757 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,18 +4,18 @@ 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?.let { myUser -> - LocalCache.users.values.filter { it.isFollowing(myUser) && account.isAcceptable(it) } - }?: emptyList() - } + override fun feed(): List { + return user?.let { myUser -> + LocalCache.users.values.filter { it.isFollowing(myUser) && 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 f5411ba0d..be4360c57 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,20 +4,20 @@ 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?.latestContactList?.unverifiedFollowKeySet()?.map { - LocalCache.getOrCreateUser(it) - } - ?.filter { account.isAcceptable(it) } - ?.reversed() ?: emptyList() - } + override fun feed(): List { + return user?.latestContactList?.unverifiedFollowKeySet()?.map { + LocalCache.getOrCreateUser(it) + } + ?.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 791b3263c..138e08217 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 c285b9334..025143a49 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 25f0bf42b..2e6b6ee7d 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 @@ -5,14 +5,14 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User 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 UserZaps.forProfileFeed(user?.zaps) - } + 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 d7879648f..b83f06155 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 1d97af2a9..76e7164ed 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 0b845a657..44e978209 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 23ffeee1f..040f1a105 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.cachedFollowCount() ?: "--"}", fontWeight = FontWeight.Bold) Text(stringResource(R.string.following)) @@ -215,20 +220,21 @@ fun ListContent( scaffoldState: ScaffoldState, modifier: Modifier, accountViewModel: AccountStateViewModel, - account: Account, + account: Account ) { 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) @@ -238,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) @@ -272,7 +278,7 @@ fun NavigationRow( tint: Color, navController: NavHostController, scaffoldState: ScaffoldState, - route: String, + route: String ) { val coroutineScope = rememberCoroutineScope() val currentRoute = currentRoute(navController) @@ -288,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 @@ -299,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 ) } } @@ -334,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( @@ -373,7 +381,8 @@ fun BottomContent(user: User, scaffoldState: ScaffoldState, navController: NavCo } if (dialogOpen) { - ShowQRDialog(user, + ShowQRDialog( + user, onScan = { dialogOpen = false coroutineScope.launch { @@ -385,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 1f6e7805e..66bed57fa 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 20f31f5b9..7cbf6a432 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 44f54722f..e25b5ba26 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 5462d4ef4..718bb233e 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 d991909da..0749741bf 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 @@ -101,16 +101,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.event as? PrivateDmEvent) @@ -142,10 +142,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 @@ -189,16 +189,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) @@ -211,11 +212,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), @@ -224,7 +224,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), @@ -232,6 +232,7 @@ fun ChannelName( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) + } if (hasNewMessages) { NewItemsBubble() 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 bce2500e9..437755f99 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,25 +242,39 @@ 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( @@ -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 fdbec03b7..e25a270cf 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 5cc2181d8..bc6a7db78 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 229c97f0f..e5e538c98 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 aaf70e181..f3ec5528a 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 @@ -49,17 +49,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 +92,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 +134,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 +182,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 +205,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 +251,6 @@ fun NoteCompose( NoteUsernameDisplay(note, Modifier.weight(1f)) } - if (noteEvent is RepostEvent) { Text( " ${stringResource(id = R.string.boosted)}", @@ -267,7 +273,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) @@ -379,13 +385,13 @@ fun NoteCompose( noteEvent.awardees() .map { LocalCache.getOrCreateUser(it) } .forEach { - UserPicture( - user = it, - navController = navController, - userAccount = account.userProfile(), - size = 35.dp - ) - } + UserPicture( + user = it, + navController = navController, + userAccount = account.userProfile(), + size = 35.dp + ) + } } note.replyTo?.firstOrNull()?.let { @@ -410,9 +416,9 @@ 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( @@ -426,8 +432,9 @@ fun NoteCompose( ) } - if (!makeItShort) + if (!makeItShort) { ReactionsRow(note, accountViewModel) + } Divider( modifier = Modifier.padding(top = 10.dp), @@ -565,9 +572,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)), @@ -587,7 +595,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 } @@ -596,14 +607,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, @@ -617,7 +627,6 @@ fun NoteAuthorPicture( } } - @Composable fun NoteAuthorPicture( baseNote: Note, @@ -636,7 +645,8 @@ fun NoteAuthorPicture( Box( Modifier .width(size) - .height(size)) { + .height(size) + ) { if (author == null) { Image( painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")), @@ -683,8 +693,8 @@ fun UserPicture( Box( Modifier .width(size) - .height(size)) { - + .height(size) + ) { AsyncImageProxy( model = ResizeImage(user.profilePicture(), size), contentDescription = stringResource(id = R.string.profile_image), @@ -696,12 +706,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 + } } ) @@ -734,7 +745,6 @@ fun UserPicture( ) } } - } } @@ -747,9 +757,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 @@ -792,35 +800,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() }) { 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 5196b42e9..07014d5ae 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 70e23fe5d..b955120a7 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 05bee3dcf..9625a2c49 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 5d64ec1ee..f4735e04d 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 17e23b7ed..7ba20cf64 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 dc9854ddc..24bb69ee1 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 e981fb80f..ddb394276 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 fc6773590..f4497bdc5 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 e1ecdcf77..6785d3599 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 1b30d7a5b..ac9669c6f 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 71ec22399..0b8ff04ea 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 @@ -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( 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 8e4976619..eb61f2ede 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 66d458e24..d82d6a353 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 df6488b03..25f3ece42 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 2f185daac..e57a4ae1b 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 77080f647..950430cc1 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 cb8a707ba..7bd61ec1d 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 b3eb028ac..b7f9b1770 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 d6da47339..6763b4d7c 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 1e4591c74..73f3e399b 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 @@ -53,7 +53,7 @@ fun ChatroomListFeedView( state = swipeRefreshState, onRefresh = { isRefreshing = true - }, + } ) { Column() { Crossfade( @@ -91,7 +91,7 @@ private fun FeedLoaded( state: FeedState.Loaded, accountViewModel: AccountViewModel, navController: NavController, - markAsRead: MutableState, + markAsRead: MutableState ) { val listState = rememberLazyListState() @@ -141,7 +141,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 4f058ac27..e0adc1bb7 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 e78a451e9..be08602fb 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 98de7538d..d77275780 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 bedef2bf8..e9fabd3bb 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 3dfae83bf..4570bef66 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 5b1a70da9..1b92b2ec0 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 50934fbab..66ce40f18 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 98a534b82..36606456c 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 6826bdcec..fc40ed0ad 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)) { @@ -315,9 +318,9 @@ fun NoteMaster(baseNote: Note, Column() { 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( 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 f5cd706e0..6a86f37e3 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 63aff4859..ed9b04b7c 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 d2ffb23ed..8748a1f11 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 171b72649..2d4485602 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 @@ -63,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 @@ -86,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) ) } @@ -125,7 +125,8 @@ private fun NSecCopyButton( onClick = { 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) @@ -170,7 +171,7 @@ private fun authenticatedCopyNSec( fun keyguardPrompt() { val intent = keyguardManager.createConfirmDeviceCredentialIntent( context.getString(R.string.app_name_release), - context.getString(R.string.copy_my_secret_key), + context.getString(R.string.copy_my_secret_key) ) keyguardLauncher.launch(intent) @@ -238,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())) @@ -250,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 e17eaba90..2b36d2fbb 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,117 @@ 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) + } +} 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 7fc6aff8b..71fa2ee6c 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 07f66a720..b416c2f2f 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 17bda6fcf..c4736e760 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 bc35e3740..8f9484d36 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 466ad2959..fb0809f79 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 3e41cd27a..fa698b275 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 ca0724285..3f438c05d 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 ef2d7938d..13f94df4d 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 af3db3b05..444e46271 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/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 10c7563ca..8f8a5914a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -88,9 +88,8 @@ fun LoginPage(accountViewModel: AccountStateViewModel) { modifier = Modifier .padding(20.dp) .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.CenterHorizontally ) { - Image( painterResource(id = R.drawable.amethyst), contentDescription = stringResource(R.string.app_logo), @@ -142,8 +141,13 @@ fun LoginPage(accountViewModel: AccountStateViewModel) { IconButton(onClick = { showPassword = !showPassword }) { Icon( imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showPassword) stringResource(R.string.show_password) else stringResource( - R.string.hide_password) + contentDescription = if (showPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password + ) + } ) } }, @@ -179,7 +183,7 @@ fun LoginPage(accountViewModel: AccountStateViewModel) { ClickableText( text = AnnotatedString(stringResource(R.string.terms_of_use)), onClick = { runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) ) } @@ -247,4 +251,4 @@ fun LoginPage(accountViewModel: AccountStateViewModel) { ) ) } -} \ No newline at end of file +} 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 d03384734..0186afcd4 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 809cbc6b9..a9542cd24 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 b40bbbe3f..ed7b30ae4 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 c5a8ccc94..18ff3dd59 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/test/java/com/vitorpamplona/amethyst/KeyParseTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/KeyParseTest.kt index f9053cabf..1ea11cf82 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?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) + 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?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) + 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?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) + 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?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) + 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?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) + 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?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) + 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?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) + 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?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) + assertEquals(",", result?.restOfWord) + } + @Test + fun keyParseTestUppercaseNoteWithExtraCharsAndNostrPrefix() { + val result = parseDirtyWordForKey("Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") + assertEquals("note", result?.type) + assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.keyHex) + 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?.type) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.keyHex) + 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 2cba993cb..0fa7ec5b1 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt @@ -6,51 +6,51 @@ 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 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 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 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 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 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 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()) - } -} \ No newline at end of file + @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 53de03970..a5fdf395c 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip19Test.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip19Test.kt @@ -6,104 +6,104 @@ import org.junit.Test class Nip19Test { - private val nip19 = Nip19(); + private val nip19 = Nip19() - @Test(expected = IllegalArgumentException::class) - fun to_int_32_length_smaller_than_4() { - toInt32(byteArrayOfInts(1, 2, 3)) - } + @Test(expected = IllegalArgumentException::class) + fun to_int_32_length_smaller_than_4() { + toInt32(byteArrayOfInts(1, 2, 3)) + } - @Test(expected = IllegalArgumentException::class) - fun to_int_32_length_bigger_than_4() { - toInt32(byteArrayOfInts(1, 2, 3, 4, 5)) - } + @Test(expected = IllegalArgumentException::class) + fun to_int_32_length_bigger_than_4() { + toInt32(byteArrayOfInts(1, 2, 3, 4, 5)) + } - @Test() - fun to_int_32_length_4() { - val actual = toInt32(byteArrayOfInts(1, 2, 3, 4)) + @Test() + fun to_int_32_length_4() { + val actual = toInt32(byteArrayOfInts(1, 2, 3, 4)) - Assert.assertEquals(16909060, actual) - } + Assert.assertEquals(16909060, actual) + } - @Ignore("Test not implemented yet") - @Test() - fun parse_TLV() { - // TODO: I don't know how to test this (?) - } + @Ignore("Test not implemented yet") + @Test() + fun parse_TLV() { + // TODO: I don't know how to test this (?) + } - @Test() - fun uri_to_route_null() { - val actual = nip19.uriToRoute(null) + @Test() + fun uri_to_route_null() { + val actual = nip19.uriToRoute(null) - Assert.assertEquals(null, actual) - } + Assert.assertEquals(null, actual) + } - @Test() - fun uri_to_route_unknown() { - val actual = nip19.uriToRoute("nostr:unknown") + @Test() + fun uri_to_route_unknown() { + val actual = nip19.uriToRoute("nostr:unknown") - Assert.assertEquals(null, actual) - } + Assert.assertEquals(null, actual) + } - @Test() - fun uri_to_route_npub() { - val actual = - nip19.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") + @Test() + fun uri_to_route_npub() { + val actual = + nip19.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals( - "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", - actual?.hex - ) - } + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals( + "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", + actual?.hex + ) + } - @Test() - fun uri_to_route_note() { - val actual = - nip19.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv") + @Test() + fun uri_to_route_note() { + val actual = + nip19.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv") - Assert.assertEquals(Nip19.Type.NOTE, actual?.type) - Assert.assertEquals( - "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", - actual?.hex - ) - } + Assert.assertEquals(Nip19.Type.NOTE, actual?.type) + Assert.assertEquals( + "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", + actual?.hex + ) + } - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nprofile() { - val actual = nip19.uriToRoute("nostr:nprofile") + @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) - } + 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") + @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) - } + 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") + @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) - } + 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") + @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) - } + Assert.assertEquals(Nip19.Type.ADDRESS, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - private fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } + 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 index 1b03f748d..8c84b0a83 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt @@ -11,46 +11,45 @@ import org.junit.Test import java.math.BigDecimal class UserZapsTest { - @Test - fun nothing() { - Assert.assertEquals(1, 1) - } + @Test + fun nothing() { + Assert.assertEquals(1, 1) + } - @Test - fun user_without_zaps() { - val actual = UserZaps.forProfileFeed(zaps = null) + @Test + fun user_without_zaps() { + val actual = UserZaps.forProfileFeed(zaps = null) - Assert.assertEquals(emptyList>(), actual) - } + Assert.assertEquals(emptyList>(), actual) + } - @Test - fun avoid_duplicates_with_same_zap_request() { - val zapRequest = mockk() + @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 zaps: Map = mapOf( + zapRequest to mockZapNoteWith("user-1", amount = 100), + zapRequest to mockZapNoteWith("user-1", amount = 200) + ) - val actual = UserZaps.forProfileFeed(zaps) + 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() - ) - } + 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 { + private fun mockZapNoteWith(pubkey: HexKey, amount: Int): Note { + val lnZapEvent = mockk() + every { lnZapEvent.amount() } returns amount.toBigDecimal() + every { lnZapEvent.pubKey() } returns pubkey - val lnZapEvent = mockk() - every { lnZapEvent.amount() } returns amount.toBigDecimal() - every { lnZapEvent.pubKey() } returns pubkey + val zapNote = mockk() + every { zapNote.event } returns lnZapEvent - val zapNote = mockk() - every { zapNote.event } returns lnZapEvent - - return zapNote - } + return zapNote + } }