From 067d694036bc5031af7bdefec80bb69ee0ce6636 Mon Sep 17 00:00:00 2001 From: Believethehype Date: Wed, 12 Apr 2023 20:49:32 +0200 Subject: [PATCH 001/164] some more community hashtags --- .../amethyst/model/HashtagIcon.kt | 12 +- app/src/main/res/drawable-xxxhdpi/catstr.xml | 704 + app/src/main/res/drawable-xxxhdpi/dogstr.xml | 232 + app/src/main/res/drawable-xxxhdpi/foodstr.xml | 25980 ++++++++++++++++ .../main/res/drawable-xxxhdpi/foodstr2.xml | 1216 + .../main/res/drawable-xxxhdpi/grownostr.xml | 64 + app/src/main/res/drawable-xxxhdpi/kiss.xml | 7837 +++++ 7 files changed, 36041 insertions(+), 4 deletions(-) create mode 100644 app/src/main/res/drawable-xxxhdpi/catstr.xml create mode 100644 app/src/main/res/drawable-xxxhdpi/dogstr.xml create mode 100644 app/src/main/res/drawable-xxxhdpi/foodstr.xml create mode 100644 app/src/main/res/drawable-xxxhdpi/foodstr2.xml create mode 100644 app/src/main/res/drawable-xxxhdpi/grownostr.xml create mode 100644 app/src/main/res/drawable-xxxhdpi/kiss.xml diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index f322db5da..965a527c5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -6,15 +6,19 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R fun checkForHashtagWithIcon(tag: String): HashtagIcon? { return when (tag.lowercase()) { - "bitcoin", "btc" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp)) - "nostr", "nostrich", "nostriches", "nostrichs" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp)) + "bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp)) + "nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp)) "lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp)) "zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp)) "amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp)) "plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp)) - "coffee", "coffeechain" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp)) + "coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp)) "skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp)) - + "foodstr", "pizza" -> HashtagIcon(R.drawable.foodstr, "Foodstr", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp)) + "dogstr", "npups" -> HashtagIcon(R.drawable.dogstr, "Dogstr", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) + "catstr", "catestr" -> HashtagIcon(R.drawable.catstr, "Catstr", Color.Unspecified, Modifier.padding(1.dp, 4.dp, 0.dp, 1.dp)) + "babechain", "kiss" -> HashtagIcon(R.drawable.kiss, "Babechain", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) + "grownostr" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) else -> null } } diff --git a/app/src/main/res/drawable-xxxhdpi/catstr.xml b/app/src/main/res/drawable-xxxhdpi/catstr.xml new file mode 100644 index 000000000..117b6d738 --- /dev/null +++ b/app/src/main/res/drawable-xxxhdpi/catstr.xml @@ -0,0 +1,704 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/dogstr.xml b/app/src/main/res/drawable-xxxhdpi/dogstr.xml new file mode 100644 index 000000000..c599a34ad --- /dev/null +++ b/app/src/main/res/drawable-xxxhdpi/dogstr.xml @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/foodstr.xml b/app/src/main/res/drawable-xxxhdpi/foodstr.xml new file mode 100644 index 000000000..1e5689e9b --- /dev/null +++ b/app/src/main/res/drawable-xxxhdpi/foodstr.xml @@ -0,0 +1,25980 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/foodstr2.xml b/app/src/main/res/drawable-xxxhdpi/foodstr2.xml new file mode 100644 index 000000000..49710b15c --- /dev/null +++ b/app/src/main/res/drawable-xxxhdpi/foodstr2.xml @@ -0,0 +1,1216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/grownostr.xml b/app/src/main/res/drawable-xxxhdpi/grownostr.xml new file mode 100644 index 000000000..c081151b7 --- /dev/null +++ b/app/src/main/res/drawable-xxxhdpi/grownostr.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/kiss.xml b/app/src/main/res/drawable-xxxhdpi/kiss.xml new file mode 100644 index 000000000..7e2a85c91 --- /dev/null +++ b/app/src/main/res/drawable-xxxhdpi/kiss.xml @@ -0,0 +1,7837 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From ff490a2b14ac21b5570c9d4020afbe4e105a8f57 Mon Sep 17 00:00:00 2001 From: Believethehype Date: Thu, 13 Apr 2023 00:15:36 +0200 Subject: [PATCH 002/164] Send Private Zaps --- .../amethyst/service/model/LnZapEvent.kt | 2 +- .../service/model/LnZapRequestEvent.kt | 80 +++++++++++++++++-- .../amethyst/ui/note/ZapCustomDialog.kt | 1 + 3 files changed, 74 insertions(+), 9 deletions(-) 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 1812fb803..9f3921c67 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 @@ -72,7 +72,7 @@ class LnZapEvent( enum class ZapType() { PUBLIC, - PRIVATE, // not yet implemented + PRIVATE, ANONYMOUS, NONZAP } 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 34fddc02a..660e27d9a 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 @@ -1,9 +1,14 @@ package com.vitorpamplona.amethyst.service.model -import com.vitorpamplona.amethyst.model.HexKey -import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.model.* +import nostr.postr.Bech32 import nostr.postr.Utils -import java.util.Date +import java.nio.charset.Charset +import java.security.SecureRandom +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec class LnZapRequestEvent( id: HexKey, @@ -28,7 +33,7 @@ class LnZapRequestEvent( zapType: LnZapEvent.ZapType, createdAt: Long = Date().time / 1000 ): LnZapRequestEvent { - val content = message + var content = message var privkey = privateKey var pubKey = Utils.pubkeyCreate(privateKey).toHexKey() var tags = listOf( @@ -46,12 +51,65 @@ class LnZapRequestEvent( tags = tags + listOf(listOf("anon", "")) privkey = Utils.privkeyCreate() pubKey = Utils.pubkeyCreate(privkey).toHexKey() + } else if (zapType == LnZapEvent.ZapType.PRIVATE) { + var enc_prkey = create_private_key(privateKey, originalNote.id(), createdAt) + var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() + var privreq = encrypt_privatezap_message(noteJson, enc_prkey, originalNote.pubKey().toByteArray()) + tags = tags + listOf(listOf("anon", privreq)) + content = "" + privkey = enc_prkey + pubKey = Utils.pubkeyCreate(enc_prkey).toHexKey() } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privkey) return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) } + fun create_private_key(privkey: ByteArray, id: String, createdAt: Long): ByteArray { + var str = privkey.toHexKey() + id + createdAt.toString() + var strbyte = str.toByteArray(Charset.forName("utf-8")) + return sha256.digest(strbyte) + } + + fun encrypt_privatezap_message(msg: String, privkey: ByteArray, pubkey: ByteArray): String { + var sharedSecret = Utils.getSharedSecret(privkey, pubkey) + val iv = ByteArray(16) + SecureRandom().nextBytes(iv) + + val keySpec = SecretKeySpec(sharedSecret, "AES") + val ivSpec = IvParameterSpec(iv) + + var utf8_message = msg.toByteArray(Charset.forName("utf-8")) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + val encryptedMsg = cipher.doFinal(utf8_message) + + val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32) + val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32) + + return encryptedMsgBech32 + "_" + ivBech32 + } + + fun decrypt_privatezap_message(msg: String, privkey: ByteArray, pubkey: ByteArray): String { + var sharedSecret = Utils.getSharedSecret(privkey, pubkey) + val parts = msg.split("_") + val iv = parts[1].run { Bech32.decode(this) } + val encryptedMsg = parts.first().run { Bech32.decode(this) } + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv.second, 0))) + return String(cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0))) + } + + fun test_decrypt(privateKey: ByteArray, event: Event): String { + val anonTag = event.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" } + var encnote = anonTag?.elementAt(1) + if (encnote != null) { + var enc_prkey2 = create_private_key(privateKey, event.id(), event.createdAt) + return decrypt_privatezap_message(encnote, enc_prkey2, event.pubKey().toByteArray()) + } else { + return "" + } + } fun create( userHex: String, relays: Set, @@ -60,7 +118,7 @@ class LnZapRequestEvent( zapType: LnZapEvent.ZapType, createdAt: Long = Date().time / 1000 ): LnZapRequestEvent { - val content = message + var content = message var privkey = privateKey var pubKey = Utils.pubkeyCreate(privateKey).toHexKey() var tags = listOf( @@ -68,18 +126,24 @@ class LnZapRequestEvent( listOf("relays") + relays ) if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - tags = tags + listOf(listOf("anon", "")) privkey = Utils.privkeyCreate() pubKey = Utils.pubkeyCreate(privkey).toHexKey() + tags = tags + listOf(listOf("anon", "")) + } else if (zapType == LnZapEvent.ZapType.PRIVATE) { + var enc_prkey = create_private_key(privateKey, userHex, createdAt) + var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() + var privreq = encrypt_privatezap_message(noteJson, enc_prkey, userHex.toByteArray()) + tags = tags + listOf(listOf("anon", privreq)) + content = "" + privkey = enc_prkey + pubKey = Utils.pubkeyCreate(enc_prkey).toHexKey() } - val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privkey) return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) } } } - /* { "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 5fe601a6a..6239a0abb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -67,6 +67,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc val zapTypes = listOf( Pair(LnZapEvent.ZapType.PUBLIC, "Public"), + Pair(LnZapEvent.ZapType.PRIVATE, "Private"), Pair(LnZapEvent.ZapType.ANONYMOUS, "Anonymous"), Pair(LnZapEvent.ZapType.NONZAP, "Non-Zap") ) From 58ff2e61573e584b413a48b9773d4a3d5bd99bde Mon Sep 17 00:00:00 2001 From: Believethehype Date: Thu, 13 Apr 2023 00:19:20 +0200 Subject: [PATCH 003/164] Update HashtagIcon.kt --- .../main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 965a527c5..f98d98532 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -18,7 +18,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? { "dogstr", "npups" -> HashtagIcon(R.drawable.dogstr, "Dogstr", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) "catstr", "catestr" -> HashtagIcon(R.drawable.catstr, "Catstr", Color.Unspecified, Modifier.padding(1.dp, 4.dp, 0.dp, 1.dp)) "babechain", "kiss" -> HashtagIcon(R.drawable.kiss, "Babechain", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) - "grownostr" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) + "grownostr", "#permies", "#perma", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) else -> null } } From a2296f23655494bcd710db293d2fb68037ac4fee Mon Sep 17 00:00:00 2001 From: Believethehype Date: Thu, 13 Apr 2023 14:38:20 +0200 Subject: [PATCH 004/164] Update grownostr.xml --- app/src/main/res/drawable-xxxhdpi/grownostr.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/drawable-xxxhdpi/grownostr.xml b/app/src/main/res/drawable-xxxhdpi/grownostr.xml index c081151b7..83dcb6f98 100644 --- a/app/src/main/res/drawable-xxxhdpi/grownostr.xml +++ b/app/src/main/res/drawable-xxxhdpi/grownostr.xml @@ -18,7 +18,7 @@ android:strokeWidth="1" android:pathData="M39,4 L40,5 L39,4 Z" /> Date: Thu, 13 Apr 2023 15:35:37 +0200 Subject: [PATCH 005/164] additional hashtags --- .../amethyst/model/HashtagIcon.kt | 7 +- app/src/main/res/drawable-xxxhdpi/bookstr.xml | 2312 +++++++++++++++++ .../main/res/drawable-xxxhdpi/foodstr2.xml | 1216 --------- app/src/main/res/drawable-xxxhdpi/footstr.xml | 12 + 4 files changed, 2329 insertions(+), 1218 deletions(-) create mode 100644 app/src/main/res/drawable-xxxhdpi/bookstr.xml delete mode 100644 app/src/main/res/drawable-xxxhdpi/foodstr2.xml create mode 100644 app/src/main/res/drawable-xxxhdpi/footstr.xml diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index f98d98532..bc9a4f71f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -15,10 +15,13 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? { "coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp)) "skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp)) "foodstr", "pizza" -> HashtagIcon(R.drawable.foodstr, "Foodstr", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp)) - "dogstr", "npups" -> HashtagIcon(R.drawable.dogstr, "Dogstr", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) + "dogstr", "npups" -> HashtagIcon(R.drawable.dogstr, "Dogstr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 0.dp)) "catstr", "catestr" -> HashtagIcon(R.drawable.catstr, "Catstr", Color.Unspecified, Modifier.padding(1.dp, 4.dp, 0.dp, 1.dp)) + "footstr", "pawstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp)) + "bookstr" -> HashtagIcon(R.drawable.bookstr, "Bookstr", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) "babechain", "kiss" -> HashtagIcon(R.drawable.kiss, "Babechain", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) - "grownostr", "#permies", "#perma", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) + "grownostr", "#permies", "#perma", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp)) + else -> null } } diff --git a/app/src/main/res/drawable-xxxhdpi/bookstr.xml b/app/src/main/res/drawable-xxxhdpi/bookstr.xml new file mode 100644 index 000000000..8c140606c --- /dev/null +++ b/app/src/main/res/drawable-xxxhdpi/bookstr.xml @@ -0,0 +1,2312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/foodstr2.xml b/app/src/main/res/drawable-xxxhdpi/foodstr2.xml deleted file mode 100644 index 49710b15c..000000000 --- a/app/src/main/res/drawable-xxxhdpi/foodstr2.xml +++ /dev/null @@ -1,1216 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/footstr.xml b/app/src/main/res/drawable-xxxhdpi/footstr.xml new file mode 100644 index 000000000..581eafc70 --- /dev/null +++ b/app/src/main/res/drawable-xxxhdpi/footstr.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file From 5e6e2835ca0087c666c6a38b430230d1a126d27b Mon Sep 17 00:00:00 2001 From: Believethehype Date: Mon, 17 Apr 2023 20:10:36 +0200 Subject: [PATCH 006/164] Receiving and decoding private Zaps Private zaps get decoded correctly. TODO: Update events or at least notifications in Damus to replace anon sender with the one from decrypted message. Bonus: Show Content of the Zap Message --- .../amethyst/model/LocalCache.kt | 22 ++++++++++ .../amethyst/service/model/Event.kt | 4 +- .../amethyst/service/model/LnZapEvent.kt | 5 +-- .../service/model/LnZapEventInterface.kt | 1 - .../service/model/LnZapRequestEvent.kt | 41 ++++++++++--------- .../amethyst/ui/screen/CardFeedViewModel.kt | 10 ++--- 6 files changed, 51 insertions(+), 32 deletions(-) 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 cef63d463..be45ab09d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -7,6 +7,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import fr.acinq.secp256k1.Hex import kotlinx.coroutines.* import nostr.postr.toNpub @@ -622,6 +623,27 @@ object LocalCache { } } + fun checkPrivateZap(zaprequest: Event): Event { + var anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" } + if (anonTag != null && anonTag.size > 1) { + var encnote = anonTag?.elementAt(1) + if (encnote != null && encnote != "") { + try { + val loggedInUserHex = NotificationFeedFilter.account.loggedIn.privKey!! // Replace without Filter + var note = LnZapRequestEvent.decrypt_privatezap_message(encnote, loggedInUserHex, zaprequest.pubKey.toByteArray()) + var decryptedEvent = Event.fromJson(note) + if (decryptedEvent.kind == 9733) { + zaprequest.pubKey = decryptedEvent.pubKey + zaprequest.content = decryptedEvent.content + // return decryptedEvent + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + return zaprequest + } fun consume(event: LnZapRequestEvent) { val note = getOrCreateNote(event.id) 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 818a2d3b2..b3e7dc182 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 @@ -15,11 +15,11 @@ import java.util.* open class Event( val id: HexKey, - @SerializedName("pubkey") val pubKey: HexKey, + @SerializedName("pubkey") var pubKey: HexKey, @SerializedName("created_at") val createdAt: Long, val kind: Int, val tags: List>, - val content: String, + var content: String, val sig: HexKey ) : EventInterface { override fun id(): HexKey = id 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 9f3921c67..eaa82509a 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 @@ -42,10 +42,9 @@ class LnZapEvent( null } } - override fun message(): String { - return message + override fun content(): String { + return content } - val message = content override fun containedPost(): Event? = try { description()?.ifBlank { null }?.let { 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 b69e02781..b60fcd5b1 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 @@ -18,5 +18,4 @@ interface LnZapEventInterface : EventInterface { fun containedPost(): Event? - fun message(): String } 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 660e27d9a..a3b3b6285 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 @@ -6,6 +6,7 @@ import nostr.postr.Utils import java.nio.charset.Charset import java.security.SecureRandom import java.util.* +import javax.crypto.BadPaddingException import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -52,21 +53,21 @@ class LnZapRequestEvent( privkey = Utils.privkeyCreate() pubKey = Utils.pubkeyCreate(privkey).toHexKey() } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - var enc_prkey = create_private_key(privateKey, originalNote.id(), createdAt) + var encryptionprkey = create_private_key(privateKey.toHexKey(), originalNote.id(), createdAt) var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() - var privreq = encrypt_privatezap_message(noteJson, enc_prkey, originalNote.pubKey().toByteArray()) + var privreq = encrypt_privatezap_message(noteJson, encryptionprkey, originalNote.pubKey().toByteArray()) tags = tags + listOf(listOf("anon", privreq)) content = "" - privkey = enc_prkey - pubKey = Utils.pubkeyCreate(enc_prkey).toHexKey() + privkey = encryptionprkey + pubKey = Utils.pubkeyCreate(encryptionprkey).toHexKey() } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privkey) return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) } - fun create_private_key(privkey: ByteArray, id: String, createdAt: Long): ByteArray { - var str = privkey.toHexKey() + id + createdAt.toString() + fun create_private_key(privkey: String, id: String, createdAt: Long): ByteArray { + var str = privkey + id + createdAt.toString() var strbyte = str.toByteArray(Charset.forName("utf-8")) return sha256.digest(strbyte) } @@ -79,10 +80,10 @@ class LnZapRequestEvent( val keySpec = SecretKeySpec(sharedSecret, "AES") val ivSpec = IvParameterSpec(iv) - var utf8_message = msg.toByteArray(Charset.forName("utf-8")) + var utf8message = msg.toByteArray(Charset.forName("utf-8")) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) - val encryptedMsg = cipher.doFinal(utf8_message) + val encryptedMsg = cipher.doFinal(utf8message) val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32) val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32) @@ -92,24 +93,26 @@ class LnZapRequestEvent( fun decrypt_privatezap_message(msg: String, privkey: ByteArray, pubkey: ByteArray): String { var sharedSecret = Utils.getSharedSecret(privkey, pubkey) + if (sharedSecret.size != 16 && sharedSecret.size != 32) { + throw IllegalArgumentException("Invalid shared secret size") + } val parts = msg.split("_") + if (parts.size != 2) { + throw IllegalArgumentException("Invalid message format") + } val iv = parts[1].run { Bech32.decode(this) } val encryptedMsg = parts.first().run { Bech32.decode(this) } val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv.second, 0))) - return String(cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0))) - } - fun test_decrypt(privateKey: ByteArray, event: Event): String { - val anonTag = event.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" } - var encnote = anonTag?.elementAt(1) - if (encnote != null) { - var enc_prkey2 = create_private_key(privateKey, event.id(), event.createdAt) - return decrypt_privatezap_message(encnote, enc_prkey2, event.pubKey().toByteArray()) - } else { - return "" + try { + val decryptedMsgBytes = cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0)) + return String(decryptedMsgBytes) + } catch (ex: BadPaddingException) { + throw IllegalArgumentException("Bad padding") } } + fun create( userHex: String, relays: Set, @@ -130,7 +133,7 @@ class LnZapRequestEvent( pubKey = Utils.pubkeyCreate(privkey).toHexKey() tags = tags + listOf(listOf("anon", "")) } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - var enc_prkey = create_private_key(privateKey, userHex, createdAt) + var enc_prkey = create_private_key(privateKey.toHexKey(), userHex, createdAt) var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() var privreq = encrypt_privatezap_message(noteJson, enc_prkey, userHex.toByteArray()) tags = tags + listOf(listOf("anon", privreq)) 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 3c83d0945..5cb55bb81 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 @@ -8,13 +8,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent -import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent -import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.service.model.LnZapEvent -import com.vitorpamplona.amethyst.service.model.PrivateDmEvent -import com.vitorpamplona.amethyst.service.model.ReactionEvent -import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter @@ -88,6 +82,8 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { if (zappedPost != null) { val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull() if (zapRequest != null) { + // var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event) + // zapRequest.event = newZapRequestEvent zapsPerEvent.getOrPut(zappedPost, { mutableMapOf() }).put(zapRequest, zapEvent) } } else { From 7a60a6804a21d484b2aee15ee196b678d94f9fb8 Mon Sep 17 00:00:00 2001 From: Believethehype Date: Mon, 17 Apr 2023 20:14:17 +0200 Subject: [PATCH 007/164] Update LnZapEventInterface.kt --- .../vitorpamplona/amethyst/service/model/LnZapEventInterface.kt | 1 - 1 file changed, 1 deletion(-) 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 b60fcd5b1..934dba55b 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 @@ -17,5 +17,4 @@ interface LnZapEventInterface : EventInterface { fun amount(): BigDecimal? fun containedPost(): Event? - } From 7ee3b8a19638dbde7da613a03daed7e1aee2c5dd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:06:12 -0400 Subject: [PATCH 008/164] Improves performance of video loading and playback. --- .../com/vitorpamplona/amethyst/VideoCache.kt | 4 +- .../vitorpamplona/amethyst/ui/MainActivity.kt | 4 ++ .../amethyst/ui/components/VideoView.kt | 58 ++++++++++++------- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt index 4d01c8301..bd785161b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt @@ -18,7 +18,7 @@ object VideoCache { lateinit var cacheDataSourceFactory: CacheDataSource.Factory - fun get(context: Context): CacheDataSource.Factory { + fun init(context: Context) { if (!this::simpleCache.isInitialized) { exoDatabaseProvider = StandaloneDatabaseProvider(context) @@ -35,7 +35,9 @@ object VideoCache { ) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) } + } + fun get(): CacheDataSource.Factory { return cacheDataSourceFactory } } 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 49823b3e4..e60c431fc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -19,6 +19,7 @@ import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager +import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.navigation.Route @@ -54,6 +55,9 @@ class MainActivity : FragmentActivity() { null } + // Initializes video cache. + VideoCache.init(this.applicationContext) + Coil.setImageLoader { ImageLoader.Builder(this).components { if (SDK_INT >= 28) { 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 ea18cd3b5..ab67d171d 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 @@ -6,9 +6,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem @@ -21,44 +25,54 @@ import com.vitorpamplona.amethyst.VideoCache @Composable fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) { val context = LocalContext.current + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) - val exoPlayer = remember { + val exoPlayer = remember(videoUri) { ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING setMediaSource( - ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri)) + ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri)) ) prepare() } } - val playerView = remember { - StyledPlayerView(context).apply { - player = exoPlayer - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - onDialog?.let { innerOnDialog -> - setFullscreenButtonClickListener { - innerOnDialog(it) + DisposableEffect( + 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 + onDialog?.let { innerOnDialog -> + setFullscreenButtonClickListener { + exoPlayer.pause() + innerOnDialog(it) + } + } } } + ) + ) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + exoPlayer.pause() + } + else -> {} + } } - } + val lifecycle = lifecycleOwner.value.lifecycle + lifecycle.addObserver(observer) - DisposableEffect(exoPlayer) { onDispose { exoPlayer.release() + lifecycle.removeObserver(observer) } } - - AndroidView( - modifier = Modifier.fillMaxWidth(), - factory = { - playerView - } - ) } From 00e54706424897f75aaccc8c43fc20cbb836aa94 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:07:03 -0400 Subject: [PATCH 009/164] Passing the size of the robohash rendering to the request in the hopes to improve performance. --- .../amethyst/ui/components/Robohash.kt | 7 ++- .../ui/components/RobohashAsyncImage.kt | 47 ++++++++++++++----- .../ui/note/ChatroomMessageCompose.kt | 1 + .../amethyst/ui/note/NoteCompose.kt | 2 + .../ui/screen/loggedIn/ProfileScreen.kt | 2 + 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt index 556c3a134..dedb0cdd4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt @@ -11,6 +11,7 @@ import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.request.ImageRequest import coil.request.Options +import coil.size.Size import okio.Buffer import java.security.MessageDigest @@ -61,6 +62,7 @@ private fun svgString(msg: String): String { class HashImageFetcher( private val context: Context, + private val size: Size, private val data: Uri ) : Fetcher { @@ -79,16 +81,17 @@ class HashImageFetcher( object Factory : Fetcher.Factory { override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { - return HashImageFetcher(options.context, data) + return HashImageFetcher(options.context, options.size, data) } } } object Robohash { - fun imageRequest(context: Context, message: String): ImageRequest { + fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest { return ImageRequest .Builder(context) .data("robohash:$message") .fetcherFactory(HashImageFetcher.Factory) + .size(robotSize) .crossfade(100) .build() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 73b500ccf..845037266 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter +import coil.size.Size @Composable fun RobohashAsyncImage( robot: String, + robotSize: Dp, modifier: Modifier = Modifier, contentDescription: String? = null, transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform, @@ -26,23 +30,30 @@ fun RobohashAsyncImage( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - AsyncImage( - model = Robohash.imageRequest(LocalContext.current, robot), - contentDescription = contentDescription, - modifier = modifier, - transform = transform, - onState = onState, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) + with(LocalDensity.current) { + AsyncImage( + model = Robohash.imageRequest( + LocalContext.current, + robot, + Size(robotSize.roundToPx(), robotSize.roundToPx()) + ), + contentDescription = contentDescription, + modifier = modifier, + transform = transform, + onState = onState, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } } @Composable fun RobohashFallbackAsyncImage( robot: String, + robotSize: Dp, model: String?, contentDescription: String?, modifier: Modifier = Modifier, @@ -53,7 +64,15 @@ fun RobohashFallbackAsyncImage( filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { val context = LocalContext.current - val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot)) + val painter = with(LocalDensity.current) { + rememberAsyncImagePainter( + model = Robohash.imageRequest( + context, + robot, + Size(robotSize.roundToPx(), robotSize.roundToPx()) + ) + ) + } AsyncImage( model = model, @@ -85,6 +104,7 @@ fun RobohashAsyncImageProxy( if (model.url == null) { RobohashAsyncImage( robot = robot, + robotSize = model.size, contentDescription = contentDescription, modifier = modifier, alignment = alignment, @@ -96,6 +116,7 @@ fun RobohashAsyncImageProxy( } else { RobohashFallbackAsyncImage( robot = robot, + robotSize = model.size, model = model.proxyUrl(), contentDescription = contentDescription, modifier = modifier, 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 bee998015..1294cf883 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 @@ -373,6 +373,7 @@ private fun RelayBadges(baseNote: Note) { ) { RobohashFallbackAsyncImage( robot = "https://$url/favicon.ico", + robotSize = 15.dp, model = "https://$url/favicon.ico", contentDescription = stringResource(id = R.string.relay_icon), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), 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 8400d174b..34c97bd9b 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 @@ -898,6 +898,7 @@ private fun RelayBadges(baseNote: Note) { ) { RobohashFallbackAsyncImage( robot = "https://$url/favicon.ico", + robotSize = 15.dp, model = "https://$url/favicon.ico", contentDescription = stringResource(R.string.relay_icon), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), @@ -970,6 +971,7 @@ fun NoteAuthorPicture( if (author == null) { RobohashAsyncImage( robot = "authornotfound", + robotSize = size, contentDescription = stringResource(R.string.unknown_author), modifier = modifier .width(size) 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 6c832ceac..2b69981de 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 @@ -655,6 +655,7 @@ fun BadgeThumb( if (image == null) { RobohashAsyncImage( robot = "authornotfound", + robotSize = size, contentDescription = stringResource(R.string.unknown_author), modifier = pictureModifier .width(size) @@ -664,6 +665,7 @@ fun BadgeThumb( } else { RobohashFallbackAsyncImage( robot = note.idHex, + robotSize = size, model = image, contentDescription = stringResource(id = R.string.profile_image), modifier = pictureModifier From b11b52b60c1ab0218688971aa77d03a5a92069a1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:07:10 -0400 Subject: [PATCH 010/164] typo --- .../java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 34c97bd9b..a09fe58ca 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 @@ -496,13 +496,13 @@ fun NoteComposeInner( noteEvent.recipientPubKey() != loggedIn.pubkeyHex && note.author !== loggedIn ) { - val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } + val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } TranslatableRichTextViewer( stringResource( id = R.string.private_conversation_notification, "@${note.author?.pubkeyNpub()}", - "@${recepient?.pubkeyNpub()}" + "@${recipient?.pubkeyNpub()}" ), canPreview = !makeItShort, Modifier.fillMaxWidth(), From 64321dbe7c648286fcbd501cc1fe35d6c901f403 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:07:34 -0400 Subject: [PATCH 011/164] No need to sort. It will be resorted later. --- .../com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 e755a8df1..8c2686973 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 @@ -20,11 +20,9 @@ import com.vitorpamplona.amethyst.model.* @Composable fun ReplyInformation(replyTo: List?, mentions: List, account: Account, navController: NavController) { - val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } - .toSet() - .sortedBy { account.userProfile().isFollowingCached(it) } + val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } - ReplyInformation(replyTo, sortedMentions, account) { + ReplyInformation(replyTo, dupMentions, account) { navController.navigate("User/${it.pubkeyHex}") } } From 42801e3ecab095b8cd78b831f21fe0f4361c4ca5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:08:32 -0400 Subject: [PATCH 012/164] Using the URL Detector (which is faster) to catch urls in the text --- .../amethyst/ui/components/RichTextViewer.kt | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) 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 474a1fea7..6a4e24272 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 @@ -32,6 +32,8 @@ 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.linkedin.urls.detection.UrlDetector +import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -132,22 +134,13 @@ fun RichTextViewer( ) } } else { - val imagesForPager = mutableListOf() - - content.split('\n').forEach { paragraph -> - paragraph.split(' ').forEach { word: String -> - // sequence of images will render in a slideview - if (isValidURL(word)) { - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - imagesForPager.add(word) - } - if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - imagesForPager.add(word) - } - } - } + val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() + val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } + val imagesForPager = urlSet.filter { fullUrl -> + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) } } + val imagesForPagerSet = imagesForPager.toSet() // FlowRow doesn't work well with paragraphs. So we need to split them content.split('\n').forEach { paragraph -> @@ -156,15 +149,10 @@ fun RichTextViewer( s.forEach { word: String -> if (canPreview) { // Explicit URL - if (isValidURL(word)) { - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableImageView(word, imagesForPager) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableImageView(word, imagesForPager) - } else { - UrlPreview(word, "$word ") - } + if (imagesForPagerSet.contains(word)) { + ZoomableImageView(word, imagesForPager) + } else if (urlSet.contains(word)) { + UrlPreview(word, "$word ") } else if (word.startsWith("lnbc", true)) { MayBeInvoicePreview(word) } else if (word.startsWith("lnurl", true)) { @@ -214,7 +202,7 @@ fun RichTextViewer( ) } } else { - if (isValidURL(word)) { + if (urlSet.contains(word)) { ClickableUrl("$word ", word) } else if (word.startsWith("lnurl", true)) { val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) From 75922f93bc919ad434cf8ee4f0f5e4dfecc4ae48 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:09:28 -0400 Subject: [PATCH 013/164] Moving ReactionsRow into a Lazy Grid --- .../amethyst/ui/note/ReactionsRow.kt | 91 +++++++++++-------- 1 file changed, 51 insertions(+), 40 deletions(-) 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 5a0d7a3b4..a60fa6184 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 @@ -9,11 +9,12 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -87,26 +88,38 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { NewPostView({ wantsToQuote = null }, null, wantsToQuote, account) } - Row( - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + Spacer(modifier = Modifier.height(8.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(5), + modifier = Modifier.height(20.dp), + userScrollEnabled = false, + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.Center ) { - ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) { - wantsToReplyTo = baseNote + items(5) { + when (it) { + 0 -> Row(verticalAlignment = CenterVertically) { + ReplyReaction(baseNote, accountViewModel) { + wantsToReplyTo = baseNote + } + } + 1 -> Row(verticalAlignment = CenterVertically) { + BoostReaction(baseNote, accountViewModel) { + wantsToQuote = baseNote + } + } + 2 -> Row(verticalAlignment = CenterVertically) { + LikeReaction(baseNote, accountViewModel) + } + 3 -> Row(verticalAlignment = CenterVertically) { + ZapReaction(baseNote, accountViewModel) + } + 4 -> Row(verticalAlignment = CenterVertically) { + ViewCountReaction(baseNote.idHex) + } + } } - - 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)) } } @@ -114,14 +127,12 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { fun ReplyReaction( 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 grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -141,29 +152,32 @@ fun ReplyReaction( } } ) { - Icon( - painter = painterResource(R.drawable.ic_comment), - null, - modifier = Modifier.size(15.dp), - tint = grayTint - ) + ReplyIcon() } if (showCounter) { Text( " ${showCount(replies.size)}", fontSize = 14.sp, - color = grayTint, - modifier = textModifier + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } } +@Composable +private fun ReplyIcon() { + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) +} + @Composable private fun BoostReaction( baseNote: Note, accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier, onQuotePress: () -> Unit ) { val boostsState by baseNote.live().boosts.observeAsState() @@ -229,16 +243,14 @@ private fun BoostReaction( Text( " ${showCount(boostedNote?.boosts?.size)}", fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = textModifier + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @Composable fun LikeReaction( baseNote: Note, - accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier + accountViewModel: AccountViewModel ) { val reactionsState by baseNote.live().reactions.observeAsState() val reactedNote = reactionsState?.note ?: return @@ -287,8 +299,7 @@ fun LikeReaction( Text( " ${showCount(reactedNote.reactions.size)}", fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = textModifier + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @@ -457,13 +468,13 @@ fun ZapReaction( } @Composable -private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) { +private fun ViewCountReaction(idHex: String) { val uri = LocalUriHandler.current val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) IconButton( modifier = Modifier.size(20.dp), - onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") } + onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") } ) { Icon( imageVector = Icons.Outlined.BarChart, @@ -473,10 +484,10 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) ) } - Row(modifier = textModifier) { + Row() { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000") + .data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000") .diskCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.ENABLED) .build(), From cfdb20892ed178e5bbb3e7e1a0bccc53e5e029f0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:53:09 -0400 Subject: [PATCH 014/164] Updating dependencies --- app/build.gradle | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c31919d7d..7af3bc96b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,7 +85,7 @@ android { } dependencies { - implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.activity:activity-compose:1.7.0-beta01' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" diff --git a/build.gradle b/build.gradle index b44afca7a..347dc9b4d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { fragment_version = "1.5.6" lifecycle_version = '2.6.1' - compose_ui_version = '1.5.0-alpha01' + compose_ui_version = '1.5.0-alpha02' nav_version = "2.5.3" room_version = "2.4.3" accompanist_version = '0.30.0' From 817c5172b6fc479b436e5100c8a6f003f7b86215 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:54:35 -0400 Subject: [PATCH 015/164] Clearing some warnings up --- .../vitorpamplona/amethyst/model/Account.kt | 4 +-- .../amethyst/model/LocalCache.kt | 5 ---- .../service/NostrHashtagDataSource.kt | 9 +++++- .../service/NostrSingleEventDataSource.kt | 5 ---- .../amethyst/service/NostrThreadDataSource.kt | 2 +- .../amethyst/ui/actions/NewMessageTagger.kt | 8 +++--- .../amethyst/ui/actions/NewPollOption.kt | 10 ------- .../amethyst/ui/actions/NewPollView.kt | 4 +-- .../amethyst/ui/actions/NewPostView.kt | 4 +-- .../amethyst/ui/components/RichTextViewer.kt | 28 ++++++++++++++----- .../amethyst/ui/components/UrlPreviewCard.kt | 8 +++--- 11 files changed, 44 insertions(+), 43 deletions(-) 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 5a36d7297..8a0a85a82 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -728,11 +728,11 @@ class Account( fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers fun followingKeySet(): Set { - return userProfile().cachedFollowingKeySet() ?: emptySet() + return userProfile().cachedFollowingKeySet() } fun followingTagSet(): Set { - return userProfile().cachedFollowingTagSet() ?: emptySet() + return userProfile().cachedFollowingTagSet() } fun isAcceptable(user: User): Boolean { 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 ee4636288..41539afa1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -700,11 +700,6 @@ object LocalCache { 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 { _ -> it.removeReply(it) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index d3fe1e4ce..81df891f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -17,7 +17,14 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { return TypedFilter( types = FeedType.values().toSet(), filter = JsonFilter( - tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())), + tags = mapOf( + "t" to listOf( + hashToLoad, + hashToLoad.lowercase(), + hashToLoad.uppercase(), + hashToLoad.capitalize() + ) + ), kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), limit = 200 ) 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 29f6cf38c..aa4e87eb5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime 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 object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { private var eventsToWatch = setOf() @@ -20,8 +19,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return null } - val now = Date().time / 1000 - return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( @@ -49,8 +46,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return null } - val now = Date().time / 1000 - return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( 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 f1502a24a..7919354c1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -25,7 +25,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { ) } - val loadEventsChannel = requestNewChannel() { eoseTime, relay -> + 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() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 516ac6e52..b0d6428f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -9,21 +9,21 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19 class NewMessageTagger(var channel: Channel?, var mentions: List?, var replyTos: List?, var message: String) { - open fun addUserToMentions(user: User) { + fun addUserToMentions(user: User) { mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user) } - open fun addNoteToReplyTos(note: Note) { + fun addNoteToReplyTos(note: Note) { note.author?.let { addUserToMentions(it) } replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note) } - open fun tagIndex(user: User): Int { + fun tagIndex(user: User): Int { // Postr Events assembles replies before mentions in the tag order return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0) } - open fun tagIndex(note: Note): Int { + fun tagIndex(note: Note): Int { // Postr Events assembles replies before mentions in the tag order return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt index 876134cff..62c607638 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -15,15 +14,6 @@ import com.vitorpamplona.amethyst.R @Composable fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) { - val colorInValid = TextFieldDefaults.outlinedTextFieldColors( - focusedBorderColor = MaterialTheme.colors.error, - unfocusedBorderColor = Color.Red - ) - val colorValid = TextFieldDefaults.outlinedTextFieldColors( - focusedBorderColor = MaterialTheme.colors.primary, - unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - Row { val deleteIcon: @Composable (() -> Unit) = { IconButton( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt index 12977d096..a238e1285 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt @@ -116,7 +116,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n Text(stringResource(R.string.poll_heading_required)) // NewPollRecipientsField(pollViewModel, account) NewPollPrimaryDescription(pollViewModel) - pollViewModel.pollOptions.values.forEachIndexed { index, element -> + pollViewModel.pollOptions.values.forEachIndexed { index, _ -> NewPollOption(pollViewModel, index) } Button( @@ -150,7 +150,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n itemsIndexed( userSuggestions, key = { _, item -> item.pubkeyHex } - ) { index, item -> + ) { _, item -> UserLine(item, account) { pollViewModel.autocompleteWithUser(item) } 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 213573b22..35210be77 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 @@ -167,7 +167,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n ) if (postViewModel.wantsPoll) { - postViewModel.pollOptions.values.forEachIndexed { index, element -> + postViewModel.pollOptions.values.forEachIndexed { index, _ -> NewPollOption(postViewModel, index) } @@ -189,7 +189,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n val user = postViewModel.account?.userProfile() val lud16 = user?.info?.lnAddress() - if (lud16 != null && user != null && postViewModel.wantsInvoice) { + if (lud16 != null && postViewModel.wantsInvoice) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { InvoiceRequest( lud16, 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 6a4e24272..a406e9337 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 @@ -180,7 +180,7 @@ fun RichTextViewer( navController ) } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, accountViewModel, navController) + HashTag(word, navController) } else { Text( text = "$word ", @@ -193,8 +193,15 @@ fun RichTextViewer( val url = matcher.group(1) // url val additionalChars = matcher.group(4) ?: "" // additional chars - ClickableUrl(url, "https://$url") - Text("$additionalChars ") + if (url != null) { + ClickableUrl(url, "https://$url") + Text("$additionalChars ") + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } } else { Text( text = "$word ", @@ -237,7 +244,7 @@ fun RichTextViewer( navController ) } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, accountViewModel, navController) + HashTag(word, navController) } else { Text( text = "$word ", @@ -250,8 +257,15 @@ fun RichTextViewer( val url = matcher.group(1) // url val additionalChars = matcher.group(4) ?: "" // additional chars - ClickableUrl(url, "https://$url") - Text("$additionalChars ") + if (url != null) { + ClickableUrl(url, "https://$url") + Text("$additionalChars ") + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } } else { Text( text = "$word ", @@ -328,7 +342,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV } @Composable -fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) { +fun HashTag(word: String, navController: NavController) { var tagSuffixPair by remember { mutableStateOf?>(null) } LaunchedEffect(key1 = word) { 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 e8453aff5..561d86ab3 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 @@ -41,24 +41,24 @@ fun UrlPreviewCard( ) ) { Column { - val url = URL(previewInfo.url) + val validatedUrl = URL(previewInfo.url) // correctly treating relative images val imageUrl = if (previewInfo.image.startsWith("/")) { - URL(url, previewInfo.image).toString() + URL(validatedUrl, previewInfo.image).toString() } else { previewInfo.image } AsyncImage( model = imageUrl, - contentDescription = stringResource(R.string.preview_card_image_for, url), + contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl), contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth() ) Text( - text = url.host, + text = validatedUrl.host, style = MaterialTheme.typography.caption, modifier = Modifier .fillMaxWidth() From d9396ce453ad62cd0123afd54acda520e2a1481f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:55:00 -0400 Subject: [PATCH 016/164] Migrating to the default Pager from Accompanist's library. --- app/build.gradle | 4 ---- .../amethyst/ui/components/SlidingCarousel.kt | 10 +++++----- .../ui/components/ZoomableImageView.kt | 5 ++--- .../amethyst/ui/navigation/AppNavigation.kt | 6 +++--- .../ui/screen/loggedIn/BookmarkListScreen.kt | 20 ++++++------------- .../ui/screen/loggedIn/ChatroomListScreen.kt | 20 ++++++------------- .../ui/screen/loggedIn/HiddenUsersScreen.kt | 20 ++++++------------- .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 20 ++++++------------- .../ui/screen/loggedIn/ProfileScreen.kt | 18 +++++------------ 9 files changed, 39 insertions(+), 84 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7af3bc96b..2af92aec8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -151,10 +151,6 @@ dependencies { // Permission to upload pictures: implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" - // tabs for user profiles - implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager - implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version" - // Parses URLs from Text: implementation "io.github.url-detector:url-detector:0.1.23" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt index 03636135b..52f2328a7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Box @@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -22,11 +25,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.PagerState -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun SlidingCarousel( modifier: Modifier = Modifier, @@ -39,7 +39,7 @@ fun SlidingCarousel( Box( modifier = modifier.fillMaxWidth() ) { - HorizontalPager(count = itemsCount, state = pagerState) { page -> + HorizontalPager(pageCount = itemsCount, state = pagerState) { page -> itemContent(page) } 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 e547da6bd..20f82bfc6 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -38,8 +39,6 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import coil.compose.AsyncImagePainter -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.PagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation @@ -130,7 +129,7 @@ fun ZoomableImageView(word: String, images: List = listOf(word)) { } } -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ZoomableImageDialog(imageUrl: String, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) { Dialog( 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 d7418272c..fc655954e 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 @@ -1,5 +1,7 @@ package com.vitorpamplona.amethyst.ui.navigation +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -11,8 +13,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun AppNavigation( navController: NavHostController, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt index df3289bca..ab92f670d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,10 +20,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -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.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter @@ -30,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -57,13 +55,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -80,7 +72,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon } ) } - HorizontalPager(count = 2, state = pagerState) { + HorizontalPager(pageCount = 2, state = pagerState) { when (pagerState.currentPage) { 0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null) 1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null) 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 6406aced9..b86669ebc 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight @@ -7,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon @@ -14,7 +17,6 @@ import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert @@ -37,10 +39,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -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.ui.dal.ChatroomListKnownFeedFilter @@ -50,7 +48,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) { val pagerState = rememberPagerState() @@ -68,13 +66,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon Box(Modifier.fillMaxWidth()) { TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -115,7 +107,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon } } - HorizontalPager(count = 2, state = pagerState) { + HorizontalPager(pageCount = 2, state = pagerState) { when (pagerState.currentPage) { 0 -> TabKnown(accountViewModel, navController, markKnownAsRead) 1 -> TabNew(accountViewModel, navController, markNewAsRead) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 436c1b0b0..40dfa3099 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,17 +19,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -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.ui.dal.HiddenAccountsFeedFilter import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.UserFeedView import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -45,13 +43,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -61,7 +53,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont } ) } - HorizontalPager(count = 1, state = pagerState) { + HorizontalPager(pageCount = 1, state = pagerState) { when (pagerState.currentPage) { 0 -> UserFeedView(feedViewModel, accountViewModel, navController) } 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 c191de3aa..af8adb2c3 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 @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -23,10 +25,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.PagerState -import com.google.accompanist.pager.pagerTabIndicatorOffset import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter @@ -39,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun HomeScreen( homeFeedViewModel: NostrHomeFeedViewModel, @@ -90,13 +88,7 @@ fun HomeScreen( ) { TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -114,7 +106,7 @@ fun HomeScreen( } ) } - HorizontalPager(count = 2, state = pagerState) { + HorizontalPager(pageCount = 2, state = pagerState) { when (pagerState.currentPage) { 0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop) 1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop) 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 2b69981de..77da720bc 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 @@ -5,6 +5,8 @@ import android.net.Uri import androidx.compose.foundation.* import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText @@ -48,10 +50,6 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil.compose.AsyncImage -import com.google.accompanist.pager.ExperimentalPagerApi -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.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -98,7 +96,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.math.BigDecimal -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -187,12 +185,6 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro ScrollableTabRow( backgroundColor = MaterialTheme.colors.background, selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - }, edgePadding = 8.dp, modifier = Modifier.onSizeChanged { tabsSize = it @@ -268,7 +260,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro } } HorizontalPager( - count = 8, + pageCount = 8, state = pagerState, modifier = with(LocalDensity.current) { Modifier.height((columnSize.height - tabsSize.height).toDp()) @@ -498,7 +490,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode userBadge.acceptedBadges?.let { note -> (note.event as? BadgeProfilesEvent)?.let { event -> - FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { + FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) { event.badgeAwardEvents().forEach { badgeAwardEvent -> val baseNote = LocalCache.notes[badgeAwardEvent] if (baseNote != null) { From 48f4fc4a0fa070bbb681da8b97b1d541e126afba Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 12:11:58 -0400 Subject: [PATCH 017/164] v0.33.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2af92aec8..56da19b5b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 118 - versionName "0.32.3" + versionCode 119 + versionName "0.33.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 0a15e079dc7ef5c0a25bdb4b921ca4aaf5efc88f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 14:08:58 -0400 Subject: [PATCH 018/164] Compares room messages by reference. --- app/src/main/java/com/vitorpamplona/amethyst/model/User.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 476f4ef7d..9696a5795 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -295,7 +295,7 @@ class User(val pubkeyHex: String) { fun hasSentMessagesTo(user: User?): Boolean { val messagesToUser = privateChatrooms[user] ?: return false - return messagesToUser.roomMessages.any { this == it.author } + return messagesToUser.roomMessages.any { this === it.author } } fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean { From 3e27567532ae00224fe7cb78b1f174ff38562273 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 14:09:26 -0400 Subject: [PATCH 019/164] Moves home and Notification bubbles to the adaptive feed. --- .../ui/dal/ChatroomListKnownFeedFilter.kt | 2 +- .../amethyst/ui/navigation/AppBottomBar.kt | 96 +++++++++++-------- .../amethyst/ui/navigation/Routes.kt | 84 +++++++++++----- 3 files changed, 114 insertions(+), 68 deletions(-) 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 ddafd9e4a..396933e94 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 @@ -10,7 +10,7 @@ object ChatroomListKnownFeedFilter : FeedFilter() { override fun feed(): List { val me = account.userProfile() - val privateChatrooms = account.userProfile().privateChatrooms + val privateChatrooms = me.privateChatrooms val messagingWith = privateChatrooms.keys.filter { me.hasSentMessagesTo(it) && account.isAcceptable(it) } 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 e275cf4f6..dd08efd7e 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 @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.navigation import android.graphics.Rect +import android.util.Log import android.view.ViewTreeObserver import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -42,6 +43,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue val bottomNavigationItems = listOf( Route.Home, @@ -135,66 +138,75 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView } } +@OptIn(ExperimentalTime::class) @Composable private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { - Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { - Icon( - painter = painterResource(id = route.icon), - contentDescription = null, - modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), - tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified - ) + println("Notifiable Icon") - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val (value, elapsed) = measureTimedValue { + Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { + Icon( + painter = painterResource(id = route.icon), + contentDescription = null, + modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), + tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified + ) - // Notification - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return + println("Notifiable Icon") - val notifState = NotificationCache.live.observeAsState() - val notif = notifState.value ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - var hasNewItems by remember { mutableStateOf(false) } + // Notification + val dbState = LocalCache.live.observeAsState() + val db = dbState.value ?: return - LaunchedEffect(key1 = notif) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache) + val notifState = NotificationCache.live.observeAsState() + val notif = notifState.value ?: return + + var hasNewItems by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = notif) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) + } } - } - LaunchedEffect(key1 = db) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache) + LaunchedEffect(key1 = db) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, db) + } } - } - if (hasNewItems) { - Box( - Modifier - .width(10.dp) - .height(10.dp) - .align(Alignment.TopEnd) - ) { + if (hasNewItems) { Box( - modifier = Modifier + Modifier .width(10.dp) .height(10.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.primary), - contentAlignment = Alignment.TopEnd + .align(Alignment.TopEnd) ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = 12.sp, + Box( modifier = Modifier - .wrapContentHeight() - .align(Alignment.TopEnd) - ) + .width(10.dp) + .height(10.dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.primary), + contentAlignment = Alignment.TopEnd + ) { + Text( + "", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = 12.sp, + modifier = Modifier + .wrapContentHeight() + .align(Alignment.TopEnd) + ) + } } } } } + + Log.d("Notification time", "$elapsed") } 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 ddbe4d307..216a5531f 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 @@ -10,6 +10,7 @@ import androidx.navigation.navArgument import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter @@ -17,7 +18,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter sealed class Route( val route: String, val icon: Int, - val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false }, + val hasNewItems: (Account, NotificationCache, Set) -> Boolean = { _, _, _ -> false }, val arguments: List = emptyList() ) { val base: String @@ -30,7 +31,7 @@ sealed class Route( navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }, navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null } ), - hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) } + hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) } ) object Search : Route( @@ -43,13 +44,13 @@ sealed class Route( route = "Notification?scrollToTop={scrollToTop}", icon = R.drawable.ic_notifications, arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }), - hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) } + hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) } ) object Message : Route( route = "Message", icon = R.drawable.ic_dm, - hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) } + hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) } ) object BlockedUsers : Route( @@ -108,36 +109,69 @@ fun currentRoute(navController: NavHostController): String? { return navBackStackEntry?.destination?.route } -private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean { - val lastTime = cache.load("HomeFollows") +object HomeLatestItem { + private var newestItem: Note? = null - HomeNewThreadFeedFilter.account = account + fun hasNewItems( + account: Account, + cache: NotificationCache, + newNotes: Set + ): Boolean { + val lastTime = cache.load("HomeFollows") + HomeNewThreadFeedFilter.account = account - return ( - HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() - ?: 0 - ) > lastTime + if (newestItem == null) { + newestItem = HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null } + } else { + newestItem = + HomeNewThreadFeedFilter.sort( + HomeNewThreadFeedFilter.applyFilter(newNotes + newestItem!!) + ).first() + } + + return (newestItem?.createdAt() ?: 0) > lastTime + } } -private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean { - val lastTime = cache.load("Notification") +object NotificationLatestItem { + private var newestItem: Note? = null - NotificationFeedFilter.account = account + fun hasNewItems( + account: Account, + cache: NotificationCache, + newNotes: Set + ): Boolean { + val lastTime = cache.load("Notification") + NotificationFeedFilter.account = account - return ( - NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() - ?: 0 - ) > lastTime + if (newestItem == null) { + newestItem = NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null } + } else { + newestItem = HomeNewThreadFeedFilter.sort( + NotificationFeedFilter.applyFilter(newNotes) + newestItem!! + ).first() + } + + return (newestItem?.createdAt() ?: 0) > lastTime + } } -private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean { - ChatroomListKnownFeedFilter.account = account +object MessagesLatestItem { + private var newestItem: Note? = null - val note = ChatroomListKnownFeedFilter.feed().firstOrNull { - it.createdAt() != null && it.channel() == null && it.author != account.userProfile() - } ?: return false + fun hasNewItems( + account: Account, + cache: NotificationCache, + newNotes: Set + ): Boolean { + ChatroomListKnownFeedFilter.account = account - val lastTime = cache.load("Room/${note.author?.pubkeyHex}") + val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull { + it.createdAt() != null && it.channel() == null && it.author != account.userProfile() + } ?: return false - return (note.createdAt() ?: 0) > lastTime + val lastTime = cache.load("Room/${note.author?.pubkeyHex}") + + return (note.createdAt() ?: 0) > lastTime + } } From fe6ef1df0a82db1fcc89d4bb58ee993502249fce Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:18:20 -0400 Subject: [PATCH 020/164] Migration to Gradle 8.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 56da19b5b..49cc2bdc4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -78,10 +78,10 @@ android { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } - - lintOptions { + lint { disable 'MissingTranslation' } + } dependencies { From cca7a103223a2650cd20e2fed2b9de764b456b73 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:18:53 -0400 Subject: [PATCH 021/164] Caching images that cause errors and avoid calling them again. --- .../ui/components/RobohashAsyncImage.kt | 68 ++++++++++++------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 845037266..c50d08163 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -50,11 +50,13 @@ fun RobohashAsyncImage( } } +var imageErrors = setOf() + @Composable fun RobohashFallbackAsyncImage( robot: String, robotSize: Dp, - model: String?, + model: String, contentDescription: String?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, @@ -63,30 +65,47 @@ fun RobohashFallbackAsyncImage( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - val context = LocalContext.current - val painter = with(LocalDensity.current) { - rememberAsyncImagePainter( - model = Robohash.imageRequest( - context, - robot, - Size(robotSize.roundToPx(), robotSize.roundToPx()) + if (imageErrors.contains(model)) { + RobohashAsyncImage( + robot = robot, + robotSize = robotSize, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } else { + val context = LocalContext.current + val painter = with(LocalDensity.current) { + rememberAsyncImagePainter( + model = Robohash.imageRequest( + context, + robot, + Size(robotSize.roundToPx(), robotSize.roundToPx()) + ) ) + } + + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + placeholder = painter, + fallback = painter, + error = painter, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality, + onError = { + imageErrors = imageErrors + model + } ) } - - AsyncImage( - model = model, - contentDescription = contentDescription, - modifier = modifier, - placeholder = painter, - fallback = painter, - error = painter, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) } @Composable @@ -101,7 +120,8 @@ fun RobohashAsyncImageProxy( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - if (model.url == null) { + val proxy = model.proxyUrl() + if (proxy == null) { RobohashAsyncImage( robot = robot, robotSize = model.size, @@ -117,7 +137,7 @@ fun RobohashAsyncImageProxy( RobohashFallbackAsyncImage( robot = robot, robotSize = model.size, - model = model.proxyUrl(), + model = proxy, contentDescription = contentDescription, modifier = modifier, alignment = alignment, From c40c7bc62ba251c8c3a197a4de9bf929413d26ad Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:19:20 -0400 Subject: [PATCH 022/164] Removing LazyGrid from reactions Row due to being slower than the Row itself. --- .../amethyst/ui/note/ReactionsRow.kt | 48 +++++++------------ 1 file changed, 18 insertions(+), 30 deletions(-) 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 a60fa6184..cf9bc9845 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 @@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -90,36 +88,26 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { Spacer(modifier = Modifier.height(8.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(5), - modifier = Modifier.height(20.dp), - userScrollEnabled = false, - horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.Center - ) { - items(5) { - when (it) { - 0 -> Row(verticalAlignment = CenterVertically) { - ReplyReaction(baseNote, accountViewModel) { - wantsToReplyTo = baseNote - } - } - 1 -> Row(verticalAlignment = CenterVertically) { - BoostReaction(baseNote, accountViewModel) { - wantsToQuote = baseNote - } - } - 2 -> Row(verticalAlignment = CenterVertically) { - LikeReaction(baseNote, accountViewModel) - } - 3 -> Row(verticalAlignment = CenterVertically) { - ZapReaction(baseNote, accountViewModel) - } - 4 -> Row(verticalAlignment = CenterVertically) { - ViewCountReaction(baseNote.idHex) - } + Row(verticalAlignment = CenterVertically) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + ReplyReaction(baseNote, accountViewModel) { + wantsToReplyTo = baseNote } } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + BoostReaction(baseNote, accountViewModel) { + wantsToQuote = baseNote + } + } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + LikeReaction(baseNote, accountViewModel) + } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + ZapReaction(baseNote, accountViewModel) + } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + ViewCountReaction(baseNote.idHex) + } } } From c7a9bd12265de7a94937128c31d09e7f2213fac3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:19:34 -0400 Subject: [PATCH 023/164] Using FlowRow to display relay icons. --- .../amethyst/ui/note/NoteCompose.kt | 141 +++++++++++------- 1 file changed, 86 insertions(+), 55 deletions(-) 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 a09fe58ca..f6d4f406c 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 @@ -5,8 +5,6 @@ import android.graphics.Bitmap import android.util.Log import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -61,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.theme.Following import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.math.BigDecimal -import kotlin.math.ceil import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -299,7 +296,7 @@ fun NoteComposeInner( } if (noteEvent is RepostEvent) { - note.replyTo?.lastOrNull()?.let { + baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it) } } else { @@ -871,68 +868,102 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U @Composable private fun RelayBadges(baseNote: Note) { val noteRelaysState by baseNote.live().relays.observeAsState() - val noteRelays = noteRelaysState?.note?.relays ?: emptySet() + val noteRelays = noteRelaysState?.note ?: return var expanded by remember { mutableStateOf(false) } + var showShowMore by remember { mutableStateOf(false) } + var lazyRelayList by remember { mutableStateOf(emptyList()) } - val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList() - val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp + LaunchedEffect(key1 = noteRelaysState, key2 = expanded) { + withContext(Dispatchers.IO) { + val relayList = noteRelays.relays.map { + it.removePrefix("wss://").removePrefix("ws://") + } - val uri = LocalUriHandler.current + val relaysToDisplay = if (expanded) relayList else relayList.take(3) + val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size - Spacer(Modifier.height(10.dp)) + if (shouldListChange) { + lazyRelayList = relaysToDisplay + } - LazyVerticalGrid( - columns = GridCells.Fixed(3), - contentPadding = PaddingValues(start = 4.dp, end = 4.dp), - modifier = Modifier.height(height), - userScrollEnabled = false - ) { - items(relaysToDisplay.size) { - val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://") - - Box( - Modifier - .padding(1.dp) - .size(15.dp) - ) { - RobohashFallbackAsyncImage( - robot = "https://$url/favicon.ico", - robotSize = 15.dp, - model = "https://$url/favicon.ico", - contentDescription = stringResource(R.string.relay_icon), - colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), - modifier = Modifier - .width(13.dp) - .height(13.dp) - .clip(shape = CircleShape) - // .border(1.dp, Color.Red) - .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://$url") }) - ) + val nextShowMore = relayList.size > 3 && !expanded + if (nextShowMore != showShowMore) { + // only triggers recomposition when actually different + showShowMore = nextShowMore } } } - if (noteRelays.size > 3 && !expanded) { - Row( - Modifier - .fillMaxWidth() - .height(25.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.Top + Spacer(Modifier.height(10.dp)) + + VerticalRelayPanelWithFlow(lazyRelayList) + + if (showShowMore) { + ShowMoreRelaysButton { + expanded = true + } + } +} + +@Composable +@Stable +private fun VerticalRelayPanelWithFlow( + relays: List +) { + // FlowRow Seems to be a lot faster than LazyVerticalGrid + FlowRow() { + relays.forEach { url -> + RelayIconCompose(url) + } + } +} + +@Composable +@Stable +private fun RelayIconCompose(url: String) { + val uri = LocalUriHandler.current + + Box( + Modifier + .padding(1.dp) + .size(15.dp) + ) { + RobohashFallbackAsyncImage( + robot = "https://$url/favicon.ico", + robotSize = 15.dp, + model = "https://$url/favicon.ico", + contentDescription = stringResource(R.string.relay_icon), + colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), + modifier = Modifier + .width(13.dp) + .height(13.dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.background) + .clickable(onClick = { uri.openUri("https://$url") }) + ) + } +} + +@Composable +private fun ShowMoreRelaysButton(onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .height(25.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Top + ) { + IconButton( + modifier = Modifier.then(Modifier.size(24.dp)), + onClick = onClick ) { - IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), - onClick = { expanded = true } - ) { - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) } } } From 48d890b354e30dcd6513a5fee2b48c1ee0c70ecf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:24:33 -0400 Subject: [PATCH 024/164] v0.33.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 49cc2bdc4..e334d8e6f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 119 - versionName "0.33.0" + versionCode 120 + versionName "0.33.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From f7de66c6a5c637a806bce021215412245e7e7fc8 Mon Sep 17 00:00:00 2001 From: Bardesss <30936735+Bardesss@users.noreply.github.com> Date: Fri, 21 Apr 2023 07:34:42 +0000 Subject: [PATCH 025/164] Update strings.xml (NL) Added missing strings. --- app/src/main/res/values-nl/strings.xml | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7063684f9..07562603e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -226,4 +226,80 @@ Heeft privésleutel Alleen lezen, geen privésleutel Terug + Selecteer + Deel browserlink + Delen + Autheur ID + Note ID + Kopieer tekst + Verwijderen + Ontvolgen + Volgen + Verzoek verwijdering + Amethyst zal vragen dat uw note wordt verwijderd van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw note permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen. + Blokkeren + Verwijderen + Blokkeren + Rapporteren + Verwijderen + Niet meer laten zien + Spam of oplichting + Godslastering of haatdragend gedrag + Kwaadwillige imitatie + Naaktheid of grafische inhoud + Illegaal gedrag + Als u een gebruiker blokkeert, wordt zijn inhoud in uw app verborgen. Uw notes zijn nog steeds publiekelijk zichtbaar, ook voor mensen die u blokkeert. Geblokkeerde gebruikers worden vermeld in het scherm Beveiligingsfilters. + + Misbruik melden + Alle geplaatste rapporteringen zijn openbaar. + Geef optioneel extra context over uw rapportering... + Extra context + Reden + Selecteer een reden… + Rapporteer + Blokkeer en rapporteer + Blokkeren + + Bladwijzers + Privé bladwijzers + PPublieke Bladwijzers + Toevoegen aan privé bladwijzers + Toevoegen aan publieke bladwijzers + Verwijderen uit privé bladwijzers + Verwijderen uit publieke bladwijzers + + Wallet Connect Service + Machtigt een Nostr Secret om zaps te betalen zonder de app te verlaten. Houd het geheim veilig en gebruik indien mogelijk een privé relays. + Wallet Connect publieke sleutel + Wallet Connect Relay + Wallet Connect Secret + Toon secret sleutel + nsec / hex privésleutel + + Bedrag in sats + Verzend poll + Verplichte velden: + Zap ontvangers + Primaire poll beschrijving… + Optie %s + Poll optie beschrijvingen + Optionele velden: + Zap minimum + Zap maximum + Consensus + (0–100)% + Sluit na + dagen + Poll is gesloten voor nieuwe stemmen + Zap bedrag + Slechts één stem per gebruiker is toegestaan bij dit type peiling. + + "Zoeken naar event %1$s" + + Een openbaar bericht toevoegen + Bedankt voor al jullie werk! + + Maken en toevoegen + Poll auteurs kunnen niet stemmen in hun eigen poll. + #zappoll From 94ee80d8ea009431d77d809260731524c18ba0d7 Mon Sep 17 00:00:00 2001 From: Bardesss <30936735+Bardesss@users.noreply.github.com> Date: Fri, 21 Apr 2023 08:59:36 +0000 Subject: [PATCH 026/164] Update strings.xml --- app/src/main/res/values-nl/strings.xml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 07562603e..3a3db447e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -226,17 +226,6 @@ Heeft privésleutel Alleen lezen, geen privésleutel Terug - Selecteer - Deel browserlink - Delen - Autheur ID - Note ID - Kopieer tekst - Verwijderen - Ontvolgen - Volgen - Verzoek verwijdering - Amethyst zal vragen dat uw note wordt verwijderd van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw note permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen. Blokkeren Verwijderen Blokkeren From a291741c37e4995328fcdc4756f9e0ec5596d016 Mon Sep 17 00:00:00 2001 From: Bardesss <30936735+Bardesss@users.noreply.github.com> Date: Fri, 21 Apr 2023 09:06:54 +0000 Subject: [PATCH 027/164] Update strings.xml --- app/src/main/res/values-nl/strings.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3a3db447e..b85757a84 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -226,12 +226,6 @@ Heeft privésleutel Alleen lezen, geen privésleutel Terug - Blokkeren - Verwijderen - Blokkeren - Rapporteren - Verwijderen - Niet meer laten zien Spam of oplichting Godslastering of haatdragend gedrag Kwaadwillige imitatie From 5e3db99675af3c945a5b8c0486ec841d912c0aa0 Mon Sep 17 00:00:00 2001 From: vivganes Date: Fri, 21 Apr 2023 17:20:50 +0530 Subject: [PATCH 028/164] fix #361 --- .../amethyst/ui/components/RichTextViewer.kt | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) 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 a406e9337..d28752b85 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 @@ -14,6 +14,7 @@ import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -125,11 +126,13 @@ fun RichTextViewer( ) ) + val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content) + MaterialRichText( style = myMarkDownStyle ) { Markdown( - content = content, + content = markdownWithSpecialContent, markdownParseOptions = MarkdownParseOptions.Default ) } @@ -280,6 +283,53 @@ fun RichTextViewer( } } +@Composable +private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? { + if (parsedNip19.type == Nip19.Type.USER) { + val userHex = parsedNip19.hex + val userBase = LocalCache.getOrCreateUser(userHex) + + val userState by userBase.live().metadata.observeAsState() + val displayName = userState?.user?.bestDisplayName() + if (displayName !== null) { + return displayName + } + } + return null +} + +@Composable +private fun returnMarkdownWithSpecialContent(content: String): String { + var returnContent = "" + content.split('\n').forEach { paragraph -> + paragraph.split(' ').forEach { word: String -> + if (isValidURL(word)) { + returnContent += "[$word]($word) " + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + returnContent += "[$word](mailto:$word) " + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + returnContent += "[$word](tel:$word) " + } else if (isBechLink(word)) { + val parsedNip19 = Nip19.uriToRoute(word) + returnContent += if (parsedNip19 !== null) { + val displayName = getDisplayNameFromUserNip19(parsedNip19) + if (displayName != null) { + "[@$displayName](nostr://$word) " + } else { + "$word " + } + } else { + "$word " + } + } else { + returnContent += "$word " + } + } + returnContent += "\n" + } + return returnContent +} + private fun isArabic(text: String): Boolean { return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } } From 8040608e1450802239dcaad068961dc9d86c1655 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 10:21:24 -0400 Subject: [PATCH 029/164] Fixes: https://github.com/vitorpamplona/amethyst/issues/359 --- .../java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index d4fd4765e..97775bfb9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -22,7 +22,7 @@ object HashtagFeedFilter : AdditiveFeedFilter() { } override fun applyFilter(collection: Set): Set { - return applyFilter(collection) + return innerApplyFilter(collection) } private fun innerApplyFilter(collection: Collection): Set { From 3f579793ca03df1293b1a03df44e5513e5bdd23f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 10:21:50 -0400 Subject: [PATCH 030/164] v0.33.2 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e334d8e6f..527fad218 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 120 - versionName "0.33.1" + versionCode 121 + versionName "0.33.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 1adb2b2caa1d2c76016cccddf2c83f8c283df607 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 11:00:06 -0400 Subject: [PATCH 031/164] Fix crashing when changing users. --- .../amethyst/ui/navigation/Routes.kt | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) 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 216a5531f..f0266f41a 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 @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter @@ -109,9 +110,29 @@ fun currentRoute(navController: NavHostController): String? { return navBackStackEntry?.destination?.route } -object HomeLatestItem { - private var newestItem: Note? = null +open class LatestItem { + var newestItemPerAccount: Map = mapOf() + fun updateNewestItem(newNotes: Set, account: Account, filter: AdditiveFeedFilter): Note? { + val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex] + + if (newestItem == null) { + newestItemPerAccount = newestItemPerAccount + Pair( + account.userProfile().pubkeyHex, + filter.feed().firstOrNull { it.createdAt() != null } + ) + } else { + newestItemPerAccount = newestItemPerAccount + Pair( + account.userProfile().pubkeyHex, + filter.sort(filter.applyFilter(newNotes) + newestItem).first() + ) + } + + return newestItemPerAccount[account.userProfile().pubkeyHex] + } +} + +object HomeLatestItem : LatestItem() { fun hasNewItems( account: Account, cache: NotificationCache, @@ -120,22 +141,13 @@ object HomeLatestItem { val lastTime = cache.load("HomeFollows") HomeNewThreadFeedFilter.account = account - if (newestItem == null) { - newestItem = HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null } - } else { - newestItem = - HomeNewThreadFeedFilter.sort( - HomeNewThreadFeedFilter.applyFilter(newNotes + newestItem!!) - ).first() - } + val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter) return (newestItem?.createdAt() ?: 0) > lastTime } } -object NotificationLatestItem { - private var newestItem: Note? = null - +object NotificationLatestItem : LatestItem() { fun hasNewItems( account: Account, cache: NotificationCache, @@ -144,21 +156,13 @@ object NotificationLatestItem { val lastTime = cache.load("Notification") NotificationFeedFilter.account = account - if (newestItem == null) { - newestItem = NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null } - } else { - newestItem = HomeNewThreadFeedFilter.sort( - NotificationFeedFilter.applyFilter(newNotes) + newestItem!! - ).first() - } + val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter) return (newestItem?.createdAt() ?: 0) > lastTime } } object MessagesLatestItem { - private var newestItem: Note? = null - fun hasNewItems( account: Account, cache: NotificationCache, From 783204b57f47b9dd348e39f1d8598f06e7f02889 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 17:01:42 -0400 Subject: [PATCH 032/164] Support for NIP-94 --- app/build.gradle | 3 + .../vitorpamplona/amethyst/model/Account.kt | 20 + .../amethyst/model/LocalCache.kt | 13 + .../amethyst/service/BlurHashDecoder.kt | 203 +++++++++ .../amethyst/service/BlurHashImage.kt | 65 +++ .../amethyst/service/FileHeader.kt | 65 +++ .../amethyst/service/NostrDataSource.kt | 1 + .../service/NostrSingleEventDataSource.kt | 9 +- .../amethyst/service/model/Event.kt | 1 + .../amethyst/service/model/FileHeaderEvent.kt | 71 +++ .../amethyst/ui/actions/ImageUploader.kt | 4 +- .../amethyst/ui/actions/NewPostViewModel.kt | 39 +- .../ui/actions/NewUserMetadataViewModel.kt | 2 +- .../amethyst/ui/components/RichTextViewer.kt | 19 +- .../ui/components/ZoomableContentView.kt | 408 ++++++++++++++++++ .../ui/components/ZoomableImageView.kt | 188 -------- .../amethyst/ui/note/NoteCompose.kt | 26 ++ .../ui/screen/loggedIn/ChannelScreen.kt | 2 + .../ui/screen/loggedIn/ChatroomScreen.kt | 2 + .../ui/screen/loggedIn/ProfileScreen.kt | 8 +- app/src/main/res/values/strings.xml | 5 + 21 files changed, 945 insertions(+), 209 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt delete mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt diff --git a/app/build.gradle b/app/build.gradle index 527fad218..db60144bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -146,6 +146,9 @@ dependencies { // view svgs implementation "io.coil-kt:coil-svg:$coil_version" + // create blurhash + implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0' + // Rendering clickable text implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" // Permission to upload pictures: 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 8a0a85a82..df4c61fb7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model import android.content.res.Resources import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData +import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Constants @@ -357,6 +358,25 @@ class Account( } } + fun sendHeader(headerInfo: FileHeader): Note? { + if (!isWriteable()) return null + + val signedEvent = FileHeaderEvent.create( + url = headerInfo.url, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + blurhash = headerInfo.blurHash, + description = headerInfo.description, + privateKey = loggedIn.privKey!! + ) + + Client.send(signedEvent) + LocalCache.consume(signedEvent) + + return LocalCache.notes[signedEvent.id] + } + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { if (!isWriteable()) return 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 41539afa1..11316a775 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -651,6 +651,19 @@ object LocalCache { refreshObservers(note) } + fun consume(event: FileHeaderEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + fun findUsersStartingWith(username: String): List { return users.values.filter { (it.anyNameStartsWith(username)) || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt new file mode 100644 index 000000000..02f980056 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt @@ -0,0 +1,203 @@ +package com.vitorpamplona.amethyst.service + +import android.graphics.Bitmap +import android.graphics.Color +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +object BlurHashDecoder { + + // cache Math.cos() calculations to improve performance. + // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps + // the cache is enabled by default, it is recommended to disable it only when just a few images are displayed + private val cacheCosinesX = HashMap() + private val cacheCosinesY = HashMap() + + /** + * Clear calculations stored in memory cache. + * The cache is not big, but will increase when many image sizes are used, + * if the app needs memory it is recommended to clear it. + */ + fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() + } + + /** + * Returns width/height + */ + fun aspectRatio(blurHash: String?): Float? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + + return numCompX.toFloat() / numCompY.toFloat() + } + + /** + * Decode a blur hash into a new bitmap. + * + * @param useCache use in memory cache for the calculated math, reused by images with same size. + * if the cache does not exist yet it will be created and populated with new calculations. + * By default it is true. + */ + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors, useCache) + } + + private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + useCache: Boolean + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when { + calculate -> { + DoubleArray(height * numCompY).also { + cacheCosinesY[height * numCompY] = it + } + } + else -> { + cacheCosinesY[height * numCompY]!! + } + } + + private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when { + calculate -> { + DoubleArray(width * numCompX).also { + cacheCosinesX[width * numCompX] = it + } + } + else -> cacheCosinesX[width * numCompX]!! + } + + private fun DoubleArray.getCos( + calculate: Boolean, + x: Int, + numComp: Int, + y: Int, + size: Int + ): Double { + if (calculate) { + this[x + numComp * y] = cos(Math.PI * y * x / size) + } + return this[x + numComp * y] + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = listOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + ) + .mapIndexed { i, c -> c to i } + .toMap() +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt new file mode 100644 index 000000000..b94f35e91 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -0,0 +1,65 @@ +package com.vitorpamplona.amethyst.service + +import android.content.Context +import android.net.Uri +import androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import coil.decode.DataSource +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.request.ImageRequest +import coil.request.Options +import java.net.URLDecoder +import java.net.URLEncoder +import kotlin.math.roundToInt + +class BlurHashFetcher( + private val options: Options, + private val data: Uri +) : Fetcher { + + override suspend fun fetch(): FetchResult { + val encodedHash = data.toString().removePrefix("bluehash:") + val hash = URLDecoder.decode(encodedHash, "utf-8") + + val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f + + val preferredWidth = 100 + + val bitmap = BlurHashDecoder.decode( + hash, + preferredWidth, + (preferredWidth * (1 / aspectRatio)).roundToInt() + ) + + if (bitmap == null) { + throw Exception("Unable to convert Bluehash $hash") + } + + return DrawableResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.MEMORY + ) + } + + object Factory : Fetcher.Factory { + override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { + return BlurHashFetcher(options, data) + } + } +} + +object BlurHashRequester { + fun imageRequest(context: Context, message: String): ImageRequest { + val encodedMessage = URLEncoder.encode(message, "utf-8") + + return ImageRequest + .Builder(context) + .data("bluehash:$encodedMessage") + .fetcherFactory(BlurHashFetcher.Factory) + .crossfade(100) + .build() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt new file mode 100644 index 000000000..a7543e56f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -0,0 +1,65 @@ +package com.vitorpamplona.amethyst.service + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.vitorpamplona.amethyst.model.toHexKey +import io.trbl.blurhash.BlurHash +import java.net.URL +import java.security.MessageDigest +import kotlin.math.roundToInt + +class FileHeader( + val url: String, + val mimeType: String?, + val hash: String, + val size: Int, + val blurHash: String?, + val description: String? = null +) { + companion object { + fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { + try { + val imageData = URL(fileUrl).readBytes() + val sha256 = MessageDigest.getInstance("SHA-256") + + val hash = sha256.digest(imageData).toHexKey() + val size = imageData.size + + val blurHash = if (mimeType?.startsWith("image/") == true) { + val opt = BitmapFactory.Options() + opt.inPreferredConfig = Bitmap.Config.ARGB_8888 + val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt) + + val intArray = IntArray(mBitmap.width * mBitmap.height) + mBitmap.getPixels( + intArray, + 0, + mBitmap.width, + 0, + 0, + mBitmap.width, + mBitmap.height + ) + + val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() + + if (aspectRatio > 1) { + BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()) + } else if (aspectRatio < 1) { + BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9) + } else { + BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4) + } + } else { + null + } + + onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, "")) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") + onError() + } + } + } +} 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 6d4fbadf9..61fa8bedf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -75,6 +75,7 @@ abstract class NostrDataSource(val debugName: String) { is ContactListEvent -> LocalCache.consume(event) is DeletionEvent -> LocalCache.consume(event) + is FileHeaderEvent -> LocalCache.consume(event) is LnZapEvent -> { event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) 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 aa4e87eb5..9a6a6568a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -111,9 +111,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { 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, - PollNoteEvent.kind, PrivateDmEvent.kind + TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, + ReactionEvent.kind, RepostEvent.kind, + LnZapEvent.kind, LnZapRequestEvent.kind, + ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, + BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, + PrivateDmEvent.kind, FileHeaderEvent.kind ), ids = interestedEvents.toList() ) 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 818a2d3b2..6d857328c 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 @@ -222,6 +222,7 @@ open class Event( ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) + FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt new file mode 100644 index 000000000..53988d943 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt @@ -0,0 +1,71 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class FileHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) + fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + + companion object { + const val kind = 1063 + + private const val URL = "url" + private const val ENCRYPTION_KEY = "aes-256-gcm" + private const val MIME_TYPE = "m" + private const val FILE_SIZE = "size" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + + fun create( + url: String, + mimeType: String? = null, + description: String? = null, + hash: String? = null, + size: String? = null, + blurhash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): FileHeaderEvent { + var tags = listOfNotNull( + listOf(URL, url), + mimeType?.let { listOf(MIME_TYPE, mimeType) }, + hash?.let { listOf(HASH, it) }, + size?.let { listOf(FILE_SIZE, it) }, + blurhash?.let { listOf(BLUR_HASH, it) }, + magnetURI?.let { listOf(MAGNET_URI, it) }, + torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) } + ) + + val content = description ?: "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} + +data class AESGCM(val key: String, val nonce: String) 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 574fef2b1..cf15b8c3a 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 @@ -15,7 +15,7 @@ object ImageUploader { fun uploadImage( uri: Uri, contentResolver: ContentResolver, - onSuccess: (String) -> Unit, + onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit ) { val contentType = contentResolver.getType(uri) @@ -64,7 +64,7 @@ object ImageUploader { "There must be an uploaded image URL in the response" } - onSuccess(url) + onSuccess(url, contentType) } } catch (e: Exception) { e.printStackTrace() 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 8be28a491..5fccd7a43 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 @@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* +import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.isValidURL @@ -110,13 +111,39 @@ open class NewPostViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, contentResolver = context.contentResolver, - onSuccess = { imageUrl -> - isUploadingImage = false - message = TextFieldValue(message.text + "\n\n" + imageUrl) - + onSuccess = { imageUrl, mimeType -> viewModelScope.launch(Dispatchers.IO) { - delay(2000) - urlPreview = findUrlInMessage() + // Images don't seem to be ready immediately after upload + + if (mimeType?.startsWith("image/") == true) { + delay(2000) + } else { + delay(5000) + } + + FileHeader.prepare( + imageUrl, + mimeType, + onReady = { + val note = account?.sendHeader(it) + + isUploadingImage = false + + if (note == null) { + message = TextFieldValue(message.text + "\n\n" + imageUrl) + } else { + message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) + } + + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) } }, onError = { 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 e6a9bf2e0..5a252ef02 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 @@ -171,7 +171,7 @@ class NewUserMetadataViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, contentResolver = context.contentResolver, - onSuccess = { imageUrl -> + onSuccess = { imageUrl, mimeType -> onUploading(false) onUploaded(imageUrl) }, 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 d28752b85..5a4fd7e74 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 @@ -139,11 +139,17 @@ fun RichTextViewer( } else { val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } - val imagesForPager = urlSet.filter { fullUrl -> + val imagesForPager = urlSet.mapNotNull { fullUrl -> val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() - imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) } - } - val imagesForPagerSet = imagesForPager.toSet() + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + ZoomableImage(fullUrl) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + ZoomableVideo(fullUrl) + } else { + null + } + }.associateBy { it.url } + val imageList = imagesForPager.values.toList() // FlowRow doesn't work well with paragraphs. So we need to split them content.split('\n').forEach { paragraph -> @@ -152,8 +158,9 @@ fun RichTextViewer( s.forEach { word: String -> if (canPreview) { // Explicit URL - if (imagesForPagerSet.contains(word)) { - ZoomableImageView(word, imagesForPager) + val img = imagesForPager[word] + if (img != null) { + ZoomableContentView(img, imageList) } else if (urlSet.contains(word)) { UrlPreview(word, "$word ") } else if (word.startsWith("lnbc", true)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt new file mode 100644 index 000000000..567730af9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -0,0 +1,408 @@ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Context +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +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.annotation.ExperimentalCoilApi +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.imageLoader +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.service.BlurHashRequester +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.ui.actions.SaveToGallery +import com.vitorpamplona.amethyst.ui.theme.Nip05 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable +import java.security.MessageDigest + +abstract class ZoomableContent( + val url: String, + val description: String? = null, + val hash: String? = null +) + +class ZoomableImage( + url: String, + description: String? = null, + hash: String? = null, + val bluehash: String? = null +) : ZoomableContent(url, description, hash) + +class ZoomableVideo( + url: String, + description: String? = null, + hash: String? = null +) : ZoomableContent(url, description, hash) + +fun figureOutMimeType(fullUrl: String): ZoomableContent { + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + + return if (isImage) { + ZoomableImage(fullUrl) + } else if (isVideo) { + ZoomableVideo(fullUrl) + } else { + ZoomableImage(fullUrl) + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun ZoomableContentView(content: ZoomableContent, images: List = listOf(content)) { + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val context = LocalContext.current + + // store the dialog open or close state + var dialogOpen by remember { + mutableStateOf(false) + } + + // store the dialog open or close state + var imageState by remember { + mutableStateOf(null) + } + + var verifiedHash by remember { + mutableStateOf(null) + } + + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + + val mainImageModifier = Modifier + .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(content.url)) } + ) + + if (content is ZoomableImage) { + Box() { + AsyncImage( + model = content.url, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = mainImageModifier, + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState !is AsyncImagePainter.State.Success) { + if (content.bluehash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } else { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + } else { + VideoView(content.url) { dialogOpen = true } + } + + if (dialogOpen) { + ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }) + } +} + +@Composable +private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { + ClickableUrl(urlText = "$content ", url = content.url) + + val myId = "inlineContent" + val emptytext = buildAnnotatedString { + withStyle( + LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() + ) { + append("") + appendInlineContent(myId, "[icon]") + } + } + val inlineContent = mapOf( + Pair( + myId, + InlineTextContent( + Placeholder( + width = 17.sp, + height = 17.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + LoadingAnimation() + } + ) + ) + + // Empty Text for Size of Icon + Text( + text = emptytext, + inlineContent = inlineContent + ) +} + +@Composable +private fun DisplayBlueHash( + content: ZoomableImage, + modifier: Modifier +) { + if (content.bluehash == null) return + + val context = LocalContext.current + AsyncImage( + model = BlurHashRequester.imageRequest( + context, + content.bluehash + ), + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = modifier + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + Column() { + val pagerState: PagerState = remember { PagerState() } + + Row( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = onDismiss) + + SaveToGallery(url = allImages[pagerState.currentPage].url) + } + + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + itemsCount = allImages.size, + itemContent = { index -> + RenderImageOrVideo(allImages[index]) + } + ) + } else { + RenderImageOrVideo(imageUrl) + } + } + } + } +} + +@Composable +private fun RenderImageOrVideo(content: ZoomableContent) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + // store the dialog open or close state + var imageState by remember { + mutableStateOf(null) + } + + var verifiedHash by remember { + mutableStateOf(null) + } + + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + + if (content is ZoomableImage) { + Box() { + AsyncImage( + model = content.url, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .zoomable(rememberZoomState()), + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + if (imageState !is AsyncImagePainter.State.Success) { + DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth()) + } else { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + } else { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + VideoView(content.url) + } + } +} + +@OptIn(ExperimentalCoilApi::class) +private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? { + if (content.hash == null) return null + + context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> + val imageFile = snapshot.data.toFile() + val bytes = imageFile.readBytes() + val sha256 = MessageDigest.getInstance("SHA-256") + + val hash = sha256.digest(bytes).toHexKey() + + Log.d("Image Hash Verification", "$hash == ${content.hash}") + + return hash == content.hash + } + + return null +} + +@Composable +private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) { + if (verifiedHash == null) return + + val localContext = LocalContext.current + val scope = rememberCoroutineScope() + + Box( + modifier + .width(40.dp) + .height(40.dp) + .padding(10.dp) + ) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colors.background) + ) + + if (verifiedHash == true) { + IconButton( + onClick = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.hash_verification_passed), + Toast.LENGTH_LONG + ).show() + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_verified), + "Hash Verified", + tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background), + modifier = Modifier.size(30.dp) + ) + } + } else if (verifiedHash == false) { + IconButton( + onClick = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.hash_verification_failed), + Toast.LENGTH_LONG + ).show() + } + } + ) { + Icon( + tint = Color.Red, + imageVector = Icons.Default.Report, + contentDescription = "Invalid Hash", + modifier = Modifier.size(30.dp) + ) + } + } + } +} 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 deleted file mode 100644 index 20f82bfc6..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ /dev/null @@ -1,188 +0,0 @@ -package com.vitorpamplona.amethyst.ui.components - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.border -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -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.AsyncImagePainter -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation -import com.vitorpamplona.amethyst.ui.actions.SaveToGallery -import net.engawapg.lib.zoomable.rememberZoomState -import net.engawapg.lib.zoomable.zoomable - -@Composable -@OptIn(ExperimentalFoundationApi::class) -fun ZoomableImageView(word: String, images: List = listOf(word)) { - 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 imageState by remember { - mutableStateOf(null) - } - - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = word, - contentDescription = word, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .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)) } - ), - onLoading = { - imageState = it - }, - onSuccess = { - imageState = it - } - ) - - if (imageState !is AsyncImagePainter.State.Success) { - ClickableUrl(urlText = "$word ", url = word) - - val myId = "inlineContent" - val emptytext = buildAnnotatedString { - withStyle( - LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() - ) { - append("") - appendInlineContent(myId, "[icon]") - } - } - val inlineContent = mapOf( - Pair( - myId, - InlineTextContent( - Placeholder( - width = 17.sp, - height = 17.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) - ) { - LoadingAnimation() - } - ) - ) - - // Empty Text for Size of Icon - Text( - text = emptytext, - inlineContent = inlineContent - ) - } - } else { - VideoView(word) { dialogOpen = true } - } - - if (dialogOpen) { - ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false }) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ZoomableImageDialog(imageUrl: String, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - Column() { - var pagerState: PagerState = remember { PagerState() } - - Row( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = onDismiss) - - SaveToGallery(url = allImages[pagerState.currentPage]) - } - - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - itemsCount = allImages.size, - itemContent = { index -> - RenderImageOrVideo(allImages[index]) - } - ) - } else { - RenderImageOrVideo(imageUrl) - } - } - } - } -} - -@Composable -private fun RenderImageOrVideo(imageUrl: String) { - val removedParamsFromUrl = imageUrl.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = imageUrl, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxSize() - .zoomable(rememberZoomState()) - ) - } else { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - VideoView(imageUrl) - } - } -} 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 f6d4f406c..a08a2336b 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 @@ -167,6 +167,8 @@ fun NoteComposeInner( ChannelHeader(baseChannel = baseChannel, account = account, navController = navController) } else if (noteEvent is BadgeDefinitionEvent) { BadgeDisplay(baseNote = note) + } else if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(note) } else { var isNew by remember { mutableStateOf(false) } @@ -776,6 +778,30 @@ fun BadgeDisplay(baseNote: Note) { } } +@Composable +fun FileHeaderDisplay(note: Note) { + val event = (note.event as? FileHeaderEvent) ?: return + + val fullUrl = event.url() ?: return + val blurHash = event.blurhash() + val hash = event.hash() + val description = event.content + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + + if (isImage || isVideo) { + val content = if (isImage) { + ZoomableImage(fullUrl, description, hash, blurHash) + } else { + ZoomableVideo(fullUrl, description, hash) + } + ZoomableContentView(content = content, listOf(content)) + } else { + UrlPreview(fullUrl, "$fullUrl ") + } +} + @Composable private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { Row( 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 32c382803..784d61262 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 @@ -89,6 +89,8 @@ fun ChannelScreen( val context = LocalContext.current val channelScreenModel: NewPostViewModel = viewModel() + channelScreenModel.account = account + if (account != null && channelId != null) { val replyTo = remember { mutableStateOf(null) } 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 0f26c18bc..07381c5e7 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 @@ -67,6 +67,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr val context = LocalContext.current val chatRoomScreenModel: NewPostViewModel = viewModel() + chatRoomScreenModel.account = account + if (account != null && userId != null) { val replyTo = remember { mutableStateOf(null) } 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 77da720bc..4d9bf93cc 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 @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.components.figureOutMimeType import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter @@ -401,8 +402,9 @@ private fun ProfileHeader( } } - if (zoomImageDialogOpen) { - ZoomableImageDialog(baseUser.profilePicture()!!, onDismiss = { zoomImageDialogOpen = false }) + val profilePic = baseUser.profilePicture() + if (zoomImageDialogOpen && profilePic != null) { + ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false }) } } @@ -706,7 +708,7 @@ private fun DrawBanner(baseUser: User) { ) if (zoomImageDialogOpen) { - ZoomableImageDialog(imageUrl = banner, onDismiss = { zoomImageDialogOpen = false }) + ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false }) } } else { Image( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1878907d..3e3109e95 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -292,4 +292,9 @@ Create and Add Poll authors can\'t vote in their own polls. #zappoll + + + Image is the same since the post + Image has changed. The author might not have seen the change + From 79e3450a4695989ca9bd198d59abd84743d88d78 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 17:07:07 -0400 Subject: [PATCH 033/164] v0.34.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index db60144bb..a9d1ca074 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 121 - versionName "0.33.2" + versionCode 122 + versionName "0.34.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From e7909beaabbef318eda0c7cc25d8eec3b1ec840f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:20:00 -0400 Subject: [PATCH 034/164] Remove spaces after bench and image previews --- .../amethyst/ui/components/RichTextViewer.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 5a4fd7e74..c2ed2dfb0 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 @@ -154,7 +154,7 @@ fun RichTextViewer( // 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(' ') + val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ') s.forEach { word: String -> if (canPreview) { // Explicit URL @@ -385,9 +385,11 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV isQuotedNote = true, navController = navController ) - Text( - "${it.second} " - ) + if (!it.second.isNullOrEmpty()) { + Text( + "${it.second} " + ) + } } ?: nip19Route?.let { ClickableRoute(it, navController) } ?: Text(text = "$word ") From 9aa5993748da1278bd112489460757f62ea40870 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:20:15 -0400 Subject: [PATCH 035/164] Removes unnecessary logs --- .../amethyst/ui/navigation/AppBottomBar.kt | 94 +++++++++---------- 1 file changed, 42 insertions(+), 52 deletions(-) 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 dd08efd7e..db0ab3644 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 @@ -1,7 +1,6 @@ package com.vitorpamplona.amethyst.ui.navigation import android.graphics.Rect -import android.util.Log import android.view.ViewTreeObserver import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -44,7 +43,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.ExperimentalTime -import kotlin.time.measureTimedValue val bottomNavigationItems = listOf( Route.Home, @@ -141,72 +139,64 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView @OptIn(ExperimentalTime::class) @Composable private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { - println("Notifiable Icon") + Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { + Icon( + painter = painterResource(id = route.icon), + contentDescription = null, + modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), + tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified + ) - val (value, elapsed) = measureTimedValue { - Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { - Icon( - painter = painterResource(id = route.icon), - contentDescription = null, - modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), - tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified - ) + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - println("Notifiable Icon") + // Notification + val dbState = LocalCache.live.observeAsState() + val db = dbState.value ?: return - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val notifState = NotificationCache.live.observeAsState() + val notif = notifState.value ?: return - // Notification - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return + var hasNewItems by remember { mutableStateOf(false) } - val notifState = NotificationCache.live.observeAsState() - val notif = notifState.value ?: return - - var hasNewItems by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = notif) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) - } + LaunchedEffect(key1 = notif) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) } + } - LaunchedEffect(key1 = db) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache, db) - } + LaunchedEffect(key1 = db) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, db) } + } - if (hasNewItems) { + if (hasNewItems) { + Box( + Modifier + .width(10.dp) + .height(10.dp) + .align(Alignment.TopEnd) + ) { Box( - Modifier + modifier = Modifier .width(10.dp) .height(10.dp) - .align(Alignment.TopEnd) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.primary), + contentAlignment = Alignment.TopEnd ) { - Box( + Text( + "", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = 12.sp, modifier = Modifier - .width(10.dp) - .height(10.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.primary), - contentAlignment = Alignment.TopEnd - ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = 12.sp, - modifier = Modifier - .wrapContentHeight() - .align(Alignment.TopEnd) - ) - } + .wrapContentHeight() + .align(Alignment.TopEnd) + ) } } } } - - Log.d("Notification time", "$elapsed") } From ed07248f7d1d8ca06c8804dcd3fd2624eb92a9c3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:20:26 -0400 Subject: [PATCH 036/164] Correctly calculates the total of Zaps in a poll --- .../vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 4ce21c372..b24d78fbd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -110,7 +110,13 @@ class PollNoteViewModel { fun totalZapped(): BigDecimal { return pollNote?.zaps?.values?.sumOf { - (it?.event as? LnZapEvent)?.amount ?: BigDecimal(0) + val zapEvent = (it?.event as? LnZapEvent) + + if (zapEvent?.zappedPollOption() != null) { + zapEvent.amount ?: BigDecimal(0) + } else { + BigDecimal(0) + } } ?: BigDecimal(0) } } From 830ade9ad67ac17fd62909c3677c100840321d39 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:22:11 -0400 Subject: [PATCH 037/164] v0.34.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a9d1ca074..5a206becf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 122 - versionName "0.34.0" + versionCode 123 + versionName "0.34.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 1d23e87bd952b10b1a04c788615422a6ba23f51e Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sat, 22 Apr 2023 05:32:48 +0200 Subject: [PATCH 038/164] refactoring --- .../amethyst/model/LocalCache.kt | 33 +++++++------------ .../amethyst/service/model/Event.kt | 4 +-- .../service/model/LnZapRequestEvent.kt | 33 +++++++++++++++---- 3 files changed, 39 insertions(+), 31 deletions(-) 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 9385bddba..e0771efb1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -4,10 +4,10 @@ import android.util.Log import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledInsert -import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import fr.acinq.secp256k1.Hex import kotlinx.coroutines.* import nostr.postr.toNpub @@ -595,6 +595,16 @@ object LocalCache { fun consume(event: LnZapEvent) { val note = getOrCreateNote(event.id) + var decryptedContent = LnZapRequestEvent.checkForPrivateZap(event.zapRequest!!, account.loggedIn.privKey!!) + if (decryptedContent != null) { + Log.e( + "DC", + "Decrypted Event: Sender: {${decryptedContent.pubKey}}, Message: {${decryptedContent.content}} " + + // TODO Update Notification with this Sender and Message + ) + } + // Already processed this event. if (note.event != null) return @@ -627,27 +637,6 @@ object LocalCache { refreshObservers(note) } - fun checkPrivateZap(zaprequest: Event): Event { - var anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" } - if (anonTag != null && anonTag.size > 1) { - var encnote = anonTag?.elementAt(1) - if (encnote != null && encnote != "") { - try { - val loggedInUserHex = NotificationFeedFilter.account.loggedIn.privKey!! // Replace without Filter - var note = LnZapRequestEvent.decrypt_privatezap_message(encnote, loggedInUserHex, zaprequest.pubKey.toByteArray()) - var decryptedEvent = Event.fromJson(note) - if (decryptedEvent.kind == 9733) { - zaprequest.pubKey = decryptedEvent.pubKey - zaprequest.content = decryptedEvent.content - // return decryptedEvent - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - return zaprequest - } fun consume(event: LnZapRequestEvent) { val note = getOrCreateNote(event.id) 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 85a47e1c9..6d857328c 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 @@ -15,11 +15,11 @@ import java.util.* open class Event( val id: HexKey, - @SerializedName("pubkey") var pubKey: HexKey, + @SerializedName("pubkey") val pubKey: HexKey, @SerializedName("created_at") val createdAt: Long, val kind: Int, val tags: List>, - var content: String, + val content: String, val sig: HexKey ) : EventInterface { override fun id(): HexKey = id 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 c08e0b1f6..e541f7382 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 @@ -55,9 +55,9 @@ class LnZapRequestEvent( privkey = Utils.privkeyCreate() pubKey = Utils.pubkeyCreate(privkey).toHexKey() } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - var encryptionprkey = create_private_key(privateKey.toHexKey(), originalNote.id(), createdAt) + var encryptionprkey = createPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt) var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() - var privreq = encrypt_privatezap_message(noteJson, encryptionprkey, originalNote.pubKey().toByteArray()) + var privreq = encryptPrivateZapMessage(noteJson, encryptionprkey, originalNote.pubKey().toByteArray()) tags = tags + listOf(listOf("anon", privreq)) content = "" privkey = encryptionprkey @@ -68,13 +68,13 @@ class LnZapRequestEvent( return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) } - fun create_private_key(privkey: String, id: String, createdAt: Long): ByteArray { + fun createPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray { var str = privkey + id + createdAt.toString() var strbyte = str.toByteArray(Charset.forName("utf-8")) return sha256.digest(strbyte) } - fun encrypt_privatezap_message(msg: String, privkey: ByteArray, pubkey: ByteArray): String { + fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String { var sharedSecret = Utils.getSharedSecret(privkey, pubkey) val iv = ByteArray(16) SecureRandom().nextBytes(iv) @@ -93,7 +93,7 @@ class LnZapRequestEvent( return encryptedMsgBech32 + "_" + ivBech32 } - fun decrypt_privatezap_message(msg: String, privkey: ByteArray, pubkey: ByteArray): String { + fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String { var sharedSecret = Utils.getSharedSecret(privkey, pubkey) if (sharedSecret.size != 16 && sharedSecret.size != 32) { throw IllegalArgumentException("Invalid shared secret size") @@ -115,6 +115,25 @@ class LnZapRequestEvent( } } + fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? { + var anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" } + if (anonTag != null && anonTag.size > 1) { + var encnote = anonTag?.elementAt(1) + if (encnote != null && encnote != "") { + try { + var note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray()) + var decryptedEvent = fromJson(note) + if (decryptedEvent.kind == 9733) { + return decryptedEvent + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + return null + } + fun create( userHex: String, relays: Set, @@ -135,9 +154,9 @@ class LnZapRequestEvent( pubKey = Utils.pubkeyCreate(privkey).toHexKey() tags = tags + listOf(listOf("anon", "")) } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - var enc_prkey = create_private_key(privateKey.toHexKey(), userHex, createdAt) + var enc_prkey = createPrivateKey(privateKey.toHexKey(), userHex, createdAt) var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() - var privreq = encrypt_privatezap_message(noteJson, enc_prkey, userHex.toByteArray()) + var privreq = encryptPrivateZapMessage(noteJson, enc_prkey, userHex.toByteArray()) tags = tags + listOf(listOf("anon", privreq)) content = "" privkey = enc_prkey From 12a3cd5113f0ff5143555d18f317f5ab28fe62da Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sat, 22 Apr 2023 05:37:52 +0200 Subject: [PATCH 039/164] Update LocalCache.kt --- .../main/java/com/vitorpamplona/amethyst/model/LocalCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e0771efb1..140a30849 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -599,7 +599,7 @@ object LocalCache { if (decryptedContent != null) { Log.e( "DC", - "Decrypted Event: Sender: {${decryptedContent.pubKey}}, Message: {${decryptedContent.content}} " + "Decrypted Content from Anon Tag: Sender: {${decryptedContent.pubKey}}, Message: {${decryptedContent.content}} " // TODO Update Notification with this Sender and Message ) From bd64a7de63f90e524e1bfd069a5a727f2248bb12 Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sat, 22 Apr 2023 05:50:38 +0200 Subject: [PATCH 040/164] clean up --- .../service/model/LnZapRequestEvent.kt | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) 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 e541f7382..221d470dd 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 @@ -55,20 +55,53 @@ class LnZapRequestEvent( privkey = Utils.privkeyCreate() pubKey = Utils.pubkeyCreate(privkey).toHexKey() } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - var encryptionprkey = createPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt) + var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt) var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() - var privreq = encryptPrivateZapMessage(noteJson, encryptionprkey, originalNote.pubKey().toByteArray()) - tags = tags + listOf(listOf("anon", privreq)) - content = "" - privkey = encryptionprkey - pubKey = Utils.pubkeyCreate(encryptionprkey).toHexKey() + var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray()) + tags = tags + listOf(listOf("anon", encryptedContent)) + content = "" // make sure public content is empty, as the content is encrypted + privkey = encryptionPrivateKey // sign event with generated privkey + pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey() // updated event with according pubkey } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privkey) return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) } - fun createPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray { + fun create( + userHex: String, + relays: Set, + privateKey: ByteArray, + message: String, + zapType: LnZapEvent.ZapType, + createdAt: Long = Date().time / 1000 + ): LnZapRequestEvent { + var content = message + var privkey = privateKey + var pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + var tags = listOf( + listOf("p", userHex), + listOf("relays") + relays + ) + if (zapType == LnZapEvent.ZapType.ANONYMOUS) { + privkey = Utils.privkeyCreate() + pubKey = Utils.pubkeyCreate(privkey).toHexKey() + tags = tags + listOf(listOf("anon", "")) + } else if (zapType == LnZapEvent.ZapType.PRIVATE) { + var enc_prkey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt) + var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() + var privreq = encryptPrivateZapMessage(noteJson, enc_prkey, userHex.toByteArray()) + tags = tags + listOf(listOf("anon", privreq)) + content = "" + privkey = enc_prkey + pubKey = Utils.pubkeyCreate(enc_prkey).toHexKey() + } + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privkey) + return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + + fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray { var str = privkey + id + createdAt.toString() var strbyte = str.toByteArray(Charset.forName("utf-8")) return sha256.digest(strbyte) @@ -116,13 +149,13 @@ class LnZapRequestEvent( } fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? { - var anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" } + val anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" } if (anonTag != null && anonTag.size > 1) { - var encnote = anonTag?.elementAt(1) + val encnote = anonTag?.elementAt(1) if (encnote != null && encnote != "") { try { - var note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray()) - var decryptedEvent = fromJson(note) + val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray()) + val decryptedEvent = fromJson(note) if (decryptedEvent.kind == 9733) { return decryptedEvent } @@ -133,39 +166,6 @@ class LnZapRequestEvent( } return null } - - fun create( - userHex: String, - relays: Set, - privateKey: ByteArray, - message: String, - zapType: LnZapEvent.ZapType, - createdAt: Long = Date().time / 1000 - ): LnZapRequestEvent { - var content = message - var privkey = privateKey - var pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - var tags = listOf( - listOf("p", userHex), - listOf("relays") + relays - ) - if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - privkey = Utils.privkeyCreate() - pubKey = Utils.pubkeyCreate(privkey).toHexKey() - tags = tags + listOf(listOf("anon", "")) - } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - var enc_prkey = createPrivateKey(privateKey.toHexKey(), userHex, createdAt) - var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() - var privreq = encryptPrivateZapMessage(noteJson, enc_prkey, userHex.toByteArray()) - tags = tags + listOf(listOf("anon", privreq)) - content = "" - privkey = enc_prkey - pubKey = Utils.pubkeyCreate(enc_prkey).toHexKey() - } - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privkey) - return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) - } } } /* From 9ae13a9f97e64a5d845880774c593cf4cd78c41f Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sat, 22 Apr 2023 05:54:43 +0200 Subject: [PATCH 041/164] Update LnZapRequestEvent.kt --- .../amethyst/service/model/LnZapRequestEvent.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 221d470dd..32583b549 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 @@ -88,13 +88,13 @@ class LnZapRequestEvent( pubKey = Utils.pubkeyCreate(privkey).toHexKey() tags = tags + listOf(listOf("anon", "")) } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - var enc_prkey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt) + var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt) var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() - var privreq = encryptPrivateZapMessage(noteJson, enc_prkey, userHex.toByteArray()) - tags = tags + listOf(listOf("anon", privreq)) + var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray()) + tags = tags + listOf(listOf("anon", encryptedContent)) content = "" - privkey = enc_prkey - pubKey = Utils.pubkeyCreate(enc_prkey).toHexKey() + privkey = encryptionPrivateKey + pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey() } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privkey) From 12a2a21c0e206a8e6f5262c24fff711a3b0a2c92 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 21 Apr 2023 21:06:19 -0700 Subject: [PATCH 042/164] Added Adaptive Icon Take advantage of themed icons in Android 13+ --- app/src/main/AndroidManifest.xml | 2 +- app/src/main/ic_launcher-playstore.png | Bin 0 -> 30953 bytes .../main/res/drawable/amethyst_monochrome.xml | 7 ++++ .../res/drawable/ic_launcher_foreground.xml | 35 ++++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 +++ .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 +++ app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2717 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4786 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1668 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2981 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3612 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 6850 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 5747 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 10937 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 7893 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15738 bytes .../res/values/ic_launcher_background.xml | 4 ++ 17 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/res/drawable/amethyst_monochrome.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values/ic_launcher_background.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3735c465f..724d0252e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,7 @@ android:name=".Amethyst" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" - android:icon="@drawable/amethyst" + android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:enableOnBackInvokedCallback="true" android:supportsRtl="true" diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..59a20b60e101172be55d90edd7400e27e72f3962 GIT binary patch literal 30953 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

;~yhu zOZ?R2Sx^=E$s13b48PP{@uG@tjHz&-`Mv|fP-b?Q`hv0Ey(%7cf!EA<-l}D1R@naz zEouxtl@Z(I`h;IegfZb@oPY5mt#H+yy6fRJN(Hud|6H+KARh(wMX0z$WTaMwgo$Q` ze#Z-eD|QPuR(G$rO-5!$r`WCD)Dy4AH_WotWT*lb1}t_@hjxj>RBkMMFWkrUPVW+B z)g+wp9_}H%th|mnE;vCRs@umL9*{QY)f=@cp!%uBfon~X>5SA)J;vTK^p|?_IWe*s zcE&_g4IlT^xc1G{Xrb9y%SFuwZkj)@EU52b$-U4*L8a^-wge}ei(zA7A!P!e*3S33 z-4WLx1h3L&65-NZOYr0I)sQk9M$1uD zcnDdDV@;LGiD3*ZyHWHb%i?J=R+f$)`_usW!pP9|wM>TBj=hvtR@6z=rHj%=wTt#F zu^8S}!(PNOIR}rB{EPmjC|vTs%+Xd^dBBQ4b_uQzC#4DOFkfXH8#sOn5a?)vliFPT zH=pA^f6>SB^LIy{)(IT20UZiv&)NPW`1#?VdkWte2sXpbbi6?Pcyzq9u<-WUaiMIK zoF;~uLDZjh#Oc^ICf}i3wso(W_CHr!)<>piAEj|rs2Gn?ys5Y^r^%-2e*ekFi1K2# zd(M>y?DXO>H|(F)72OzK09o2O=K1GS+P%|sXe6irB;xfGhgpd z&wSauxIGfT8GwQI70x_{Nx*kQgA~Uw4i~tZm<)tD%$`BOPceD;F(VLu{Qv*@X9vdE Y(Y@|dX6H%ez^-8H%r~z|WZ9ql4*-mWuK)l5 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/amethyst_monochrome.xml b/app/src/main/res/drawable/amethyst_monochrome.xml new file mode 100644 index 000000000..5109ae5ea --- /dev/null +++ b/app/src/main/res/drawable/amethyst_monochrome.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..7fb2b875f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..1934cfb45 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..ac01b19d402995aca15e20a5f29b7c1cb6e31226 GIT binary patch literal 2717 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 XknO;mGvIry00000NkvXXu0mjf`z;f2 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..010a6193915a21434ff50b44aab9116853ea59aa GIT binary patch literal 4786 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 M07*qoM6N<$g6)d+M*si- literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..2f0b4e4941dc545bb467580f3108aa3852deabdf GIT binary patch literal 1668 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�$Ak7bcLt+r;okGBk4?&gy3UZ6EducqDS^SQrZ*RcTcYepRcY`?) zt^>E=5QU@-5Ssk31HP8w@Eab<$=5QHa)UTORE8xXKlpvK4+mDK5cm?w z?^7Si4nY2`LTsKL4TrY^VgKLtaCkEij&B8NK}5JDa7no^w;1UOH*hZY3a9g=wD{|| zc=$T9Qge}$uI7|^{x&Wix`9Zi6Y%*eoZAf?2q7%#G|3sA2DHN)W2SHW<*eCyw zX^~jm$B)yJKK`(OBY+1H1n2iRLY-B&4rgg`84miNmE;F@wWOOLSnUwucoIc-i~~V7 z0P=DQ;6HX37QMcftNTL2f;%KcR2#~e#GbM6La{rq#SMsupOgWqY&$Vh!)ik zRl^bk!9tR1;c*n)uK$f4*#Pje+=7KYy|M6BAK1LM2DYzNf>=x-9Nu1!%nP?9=T)b~ zt>-B@Z>7R*JJKjDvDeDzl2&Dxz}F8|le9QLu=-)^^dpja4V7#F%zI@O=6CnPf*z|m z5HE)wZ{5^4@=^C4V7#F{Q4hH zg0K?v^?@IFxSX$QK zpQ{hV8shT}m23dac-aXv{_QLQ5je5_pF&+Reu;odc%4 z_{J>~X?tDcML2OUAynN8vMK3+yd?`D&VJH5T;n*7lcdUt6PT zeQVp^{@S*;0Kt)VgLn-?B^v<0v|fO5&o1OZSP=-P&(?BbLp-iJ<=-xZ$B##tf2nF)Lum3<*Y*~J5U*pXWCP&mXXX)v`S|5o0`c5Jcnk~F8>_3+@m*)N^@BBQ zYp`ls)Yh1%2ZXR2gtoU`?{)l5APordI)+L%0DgLEF2+9nD-U7;b~#5&##E=0!V;_* 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 w3U@-FG)W4e{9TKCRHbSm-5+=_~2v0kYEH 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 HX2kyk>QJBz literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..dbece9066a646a04ff6945144546e0e977b53f9d GIT binary patch literal 10937 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..ddd8da2e5ea244982d15a1fd8f4eecaa9f62dae3 GIT binary patch literal 15738 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..c5d5899fd --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file From a013f1f1cc71c0d6dc567705c33cbec2e984df44 Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sat, 22 Apr 2023 20:11:05 +0200 Subject: [PATCH 043/164] added new hashtag icons --- .../amethyst/model/HashtagIcon.kt | 10 +- app/src/main/res/drawable-xxxhdpi/bookstr.xml | 2312 -- app/src/main/res/drawable-xxxhdpi/foodstr.xml | 25980 ---------------- 3 files changed, 2 insertions(+), 28300 deletions(-) delete mode 100644 app/src/main/res/drawable-xxxhdpi/bookstr.xml delete mode 100644 app/src/main/res/drawable-xxxhdpi/foodstr.xml diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index bc9a4f71f..7439f2f8c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -14,14 +14,8 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? { "plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp)) "coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp)) "skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp)) - "foodstr", "pizza" -> HashtagIcon(R.drawable.foodstr, "Foodstr", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp)) - "dogstr", "npups" -> HashtagIcon(R.drawable.dogstr, "Dogstr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 0.dp)) - "catstr", "catestr" -> HashtagIcon(R.drawable.catstr, "Catstr", Color.Unspecified, Modifier.padding(1.dp, 4.dp, 0.dp, 1.dp)) - "footstr", "pawstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp)) - "bookstr" -> HashtagIcon(R.drawable.bookstr, "Bookstr", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) - "babechain", "kiss" -> HashtagIcon(R.drawable.kiss, "Babechain", Color.Unspecified, Modifier.padding(1.dp, 0.dp, 0.dp, 0.dp)) - "grownostr", "#permies", "#perma", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp)) - + "grownostr", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp)) + "footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp)) else -> null } } diff --git a/app/src/main/res/drawable-xxxhdpi/bookstr.xml b/app/src/main/res/drawable-xxxhdpi/bookstr.xml deleted file mode 100644 index 8c140606c..000000000 --- a/app/src/main/res/drawable-xxxhdpi/bookstr.xml +++ /dev/null @@ -1,2312 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/foodstr.xml b/app/src/main/res/drawable-xxxhdpi/foodstr.xml deleted file mode 100644 index 1e5689e9b..000000000 --- a/app/src/main/res/drawable-xxxhdpi/foodstr.xml +++ /dev/null @@ -1,25980 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 18dc5354bddd7161e966b24dcbc425709f2d7d2d Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sat, 22 Apr 2023 20:13:23 +0200 Subject: [PATCH 044/164] removed unused graphics --- app/src/main/res/drawable-xxxhdpi/catstr.xml | 704 -- app/src/main/res/drawable-xxxhdpi/dogstr.xml | 232 - app/src/main/res/drawable-xxxhdpi/kiss.xml | 7837 ------------------ 3 files changed, 8773 deletions(-) delete mode 100644 app/src/main/res/drawable-xxxhdpi/catstr.xml delete mode 100644 app/src/main/res/drawable-xxxhdpi/dogstr.xml delete mode 100644 app/src/main/res/drawable-xxxhdpi/kiss.xml diff --git a/app/src/main/res/drawable-xxxhdpi/catstr.xml b/app/src/main/res/drawable-xxxhdpi/catstr.xml deleted file mode 100644 index 117b6d738..000000000 --- a/app/src/main/res/drawable-xxxhdpi/catstr.xml +++ /dev/null @@ -1,704 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/dogstr.xml b/app/src/main/res/drawable-xxxhdpi/dogstr.xml deleted file mode 100644 index c599a34ad..000000000 --- a/app/src/main/res/drawable-xxxhdpi/dogstr.xml +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/kiss.xml b/app/src/main/res/drawable-xxxhdpi/kiss.xml deleted file mode 100644 index 7e2a85c91..000000000 --- a/app/src/main/res/drawable-xxxhdpi/kiss.xml +++ /dev/null @@ -1,7837 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 9c715dd381c1413f4f0b2b25a9035aab23b7dcc7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 22 Apr 2023 15:02:33 -0400 Subject: [PATCH 045/164] Fixing the position of the image loading animation --- .../ui/components/ZoomableContentView.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 567730af9..496697e62 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -148,6 +148,14 @@ fun ZoomableContentView(content: ZoomableContent, images: List ) if (content is ZoomableImage) { + if (imageState !is AsyncImagePainter.State.Success) { + if (content.bluehash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } + Box() { AsyncImage( model = content.url, @@ -162,13 +170,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List } ) - if (imageState !is AsyncImagePainter.State.Success) { - if (content.bluehash != null) { - DisplayBlueHash(content, mainImageModifier) - } else { - DisplayUrlWithLoadingSymbol(content) - } - } else { + if (imageState is AsyncImagePainter.State.Success) { HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) } } @@ -183,7 +185,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List @Composable private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { - ClickableUrl(urlText = "$content ", url = content.url) + ClickableUrl(urlText = "${content.url} ", url = content.url) val myId = "inlineContent" val emptytext = buildAnnotatedString { From c1d05f8b2fb5af39f91075d47754e46b94878617 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 22 Apr 2023 15:14:00 -0400 Subject: [PATCH 046/164] Adds some margin for reply quotes --- .../ui/components/ZoomableContentView.kt | 16 ++++++++-------- .../amethyst/ui/note/NoteCompose.kt | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 496697e62..69b053577 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -148,14 +148,6 @@ fun ZoomableContentView(content: ZoomableContent, images: List ) if (content is ZoomableImage) { - if (imageState !is AsyncImagePainter.State.Success) { - if (content.bluehash != null) { - DisplayBlueHash(content, mainImageModifier) - } else { - DisplayUrlWithLoadingSymbol(content) - } - } - Box() { AsyncImage( model = content.url, @@ -174,6 +166,14 @@ fun ZoomableContentView(content: ZoomableContent, images: List HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) } } + + if (imageState !is AsyncImagePainter.State.Success) { + if (content.bluehash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } } else { VideoView(content.url) { dialogOpen = true } } 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 a08a2336b..bbcf20e2b 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 @@ -375,7 +375,7 @@ fun NoteComposeInner( baseNote = replyingDirectlyTo, isQuotedNote = true, modifier = Modifier - .padding(0.dp) + .padding(top = 5.dp) .fillMaxWidth() .clip(shape = RoundedCornerShape(15.dp)) .border( From 37bd7e34a0517ac59b1b20c7cf90c2ce4708412a Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sun, 23 Apr 2023 18:53:38 +0200 Subject: [PATCH 047/164] remember default ZapType Based on last selection in ZapCustomDialog, Reaction Row Zaps will use the same zap type Currently Zaps in Polls are always public. --- .../com/vitorpamplona/amethyst/LocalPreferences.kt | 9 +++++++++ .../java/com/vitorpamplona/amethyst/model/Account.kt | 7 +++++++ .../amethyst/ui/components/InvoiceRequest.kt | 3 +-- .../com/vitorpamplona/amethyst/ui/note/PollNote.kt | 2 +- .../com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 7 +++---- .../vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt | 10 ++++++---- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index c2c21bb24..f71ef1439 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent +import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.note.Nip47URI import fr.acinq.secp256k1.Hex import nostr.postr.Persona @@ -42,6 +43,7 @@ private object PrefKeys { const val LANGUAGE_PREFS = "languagePreferences" const val TRANSLATE_TO = "translateTo" const val ZAP_AMOUNTS = "zapAmounts" + const val DEFAULT_ZAPTYPE = "defaultZapType" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" @@ -191,6 +193,7 @@ object LocalPreferences { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences)) putString(PrefKeys.TRANSLATE_TO, account.translateTo) putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices)) + putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType)) putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) @@ -217,6 +220,11 @@ object LocalPreferences { object : TypeToken>() {}.type ) ?: listOf(500L, 1000L, 5000L) + val defaultZapType = gson.fromJson( + getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"), + object : TypeToken() {}.type + ) ?: LnZapEvent.ZapType.PUBLIC + val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { gson.fromJson(it, Nip47URI::class.java) @@ -260,6 +268,7 @@ object LocalPreferences { languagePreferences, translateTo, zapAmountChoices, + defaultZapType, zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, 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 df4c61fb7..0aa093724 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -43,6 +43,7 @@ class Account( var languagePreferences: Map = mapOf(), var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), + var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -625,6 +626,12 @@ class Account( saveable.invalidateData() } + fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { + defaultZapType = zapType + live.invalidateData() + saveable.invalidateData() + } + fun changeZapAmounts(newAmounts: List) { zapAmountChoices = newAmounts live.invalidateData() 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 06e38c773..a5c417728 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 @@ -37,7 +37,6 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver -import com.vitorpamplona.amethyst.service.model.LnZapEvent import kotlinx.coroutines.launch @Composable @@ -136,7 +135,7 @@ fun InvoiceRequest( Button( modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), onClick = { - val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, LnZapEvent.ZapType.PUBLIC) + val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType) LightningAddressResolver().lnAddressInvoice( lud16, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 26e83cf91..28ffb025f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -250,7 +250,7 @@ fun ZapVote( zappingProgress = it } }, - zapType = LnZapEvent.ZapType.PUBLIC + zapType = account.defaultZapType ) } } else { 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 cf9bc9845..ee494d2d4 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 @@ -54,7 +54,6 @@ import coil.request.CachePolicy import coil.request.ImageRequest import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -364,7 +363,7 @@ fun ZapReaction( zappingProgress = it } }, - zapType = LnZapEvent.ZapType.PUBLIC + zapType = account.defaultZapType ) } } else if (account.zapAmountChoices.size > 1) { @@ -561,7 +560,7 @@ fun ZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -587,7 +586,7 @@ fun ZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 6239a0abb..521d52486 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.launch class ZapOptionstViewModel : ViewModel() { private var account: Account? = null + var customAmount by mutableStateOf(TextFieldValue("21")) var customMessage by mutableStateOf(TextFieldValue("")) @@ -73,7 +74,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc ) val zapOptions = zapTypes.map { it.second } - var selectedZapType by remember { mutableStateOf(zapTypes[0]) } + var selectedZapType by remember { mutableStateOf(account.defaultZapType) } Dialog( onDismissRequest = { onClose() }, @@ -116,7 +117,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc zappingProgress = it } }, - zapType = selectedZapType.first + zapType = selectedZapType ) } onClose() @@ -186,10 +187,11 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc } TextSpinner( label = "Zap Type", - placeholder = "Public", + placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second, options = zapOptions, onSelect = { - selectedZapType = zapTypes[it] + selectedZapType = zapTypes[it].first + account.changeDefaultZapType(selectedZapType) }, modifier = Modifier.fillMaxWidth() ) From ed4c19ac79e564ab52a61ef449ce29204749860c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 23 Apr 2023 16:10:09 -0400 Subject: [PATCH 048/164] Adjusts the Wallet Connect API to the new RPC design --- app/src/main/AndroidManifest.xml | 1 + .../vitorpamplona/amethyst/model/Account.kt | 15 +++- .../amethyst/model/LocalCache.kt | 18 ++++ .../vitorpamplona/amethyst/service/Nip47.kt | 2 +- .../service/NostrAccountDataSource.kt | 1 + .../amethyst/service/NostrDataSource.kt | 8 ++ .../NostrLnZapPaymentResponseDataSource.kt | 39 +++++++++ .../amethyst/service/model/Event.kt | 1 + .../service/model/LnZapPaymentRequestEvent.kt | 24 +++++- .../model/LnZapPaymentResponseEvent.kt | 83 +++++++++++++++++++ .../amethyst/service/relays/Client.kt | 40 +++++++-- .../amethyst/service/relays/Relay.kt | 39 +++++++-- .../ui/screen/loggedIn/AccountViewModel.kt | 18 +++- .../ui/screen/loggedIn/ProfileScreen.kt | 28 ++++++- 14 files changed, 297 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 724d0252e..4316e8668 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,7 @@ + Unit) { if (!isWriteable()) return zapPaymentRequest?.let { - val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) + val event = LnZapPaymentRequestEvent.create(bolt11, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) - Client.send(event, it.relayUri) + val wcListener = NostrLnZapPaymentResponseDataSource(it.pubKeyHex, loggedIn.pubKey.toHexKey(), event.id) + wcListener.start() + + LocalCache.consume(event, onResponse) + Client.send(event, it.relayUri, wcListener.feedTypes) { + wcListener.destroy() + } } } 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 140a30849..4554deb62 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -31,6 +31,8 @@ object LocalCache { val channels = ConcurrentHashMap() val addressables = ConcurrentHashMap(100) + val awaitingPaymentRequests = ConcurrentHashMap Unit>(10) + fun checkGetOrCreateUser(key: String): User? { if (isValidHexNpub(key)) { return getOrCreateUser(key) @@ -675,6 +677,22 @@ object LocalCache { refreshObservers(note) } + fun consume(event: LnZapPaymentRequestEvent) { + // Does nothing without a response callback. + } + + fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) { + awaitingPaymentRequests.put(event.id, onResponse) + } + + fun consume(event: LnZapPaymentResponseEvent) { + val responseCallback = awaitingPaymentRequests[event.requestId()] + + if (responseCallback != null) { + responseCallback(event) + } + } + fun findUsersStartingWith(username: String): List { return users.values.filter { (it.anyNameStartsWith(username)) || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt index 4fb1bdcde..854cf4b1c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt @@ -14,7 +14,7 @@ object Nip47 { val url = Uri.parse(uri) - if (url.scheme != "nostrwalletconnect") { + if (url.scheme != "nostrwalletconnect" || url.scheme != "nostr+walletconnect") { throw IllegalArgumentException("Not a Wallet Connect QR Code") } 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 cb867066c..1d2327426 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -88,6 +88,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, + LnZapPaymentResponseEvent.kind, ChannelMessageEvent.kind, BadgeAwardEvent.kind ), 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 61fa8bedf..206a6538a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.util.Date import java.util.UUID +import kotlin.Error abstract class NostrDataSource(val debugName: String) { private var subscriptions = mapOf() @@ -81,6 +82,8 @@ abstract class NostrDataSource(val debugName: String) { LocalCache.consume(event) } is LnZapRequestEvent -> LocalCache.consume(event) + is LnZapPaymentRequestEvent -> LocalCache.consume(event) + is LnZapPaymentResponseEvent -> LocalCache.consume(event) is LongTextNoteEvent -> LocalCache.consume(event, relay) is MetadataEvent -> LocalCache.consume(event) is PrivateDmEvent -> LocalCache.consume(event, relay) @@ -129,6 +132,11 @@ abstract class NostrDataSource(val debugName: String) { Client.subscribe(clientListener) } + fun destroy() { + stop() + Client.unsubscribe(clientListener) + } + open fun start() { println("DataSource: ${this.javaClass.simpleName} Start") resetFilters() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt new file mode 100644 index 000000000..faa301cce --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt @@ -0,0 +1,39 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter + +class NostrLnZapPaymentResponseDataSource( + private var fromServiceHex: String, + private var toUserHex: String, + private var replyingToHex: String, +): NostrDataSource("LnZapPaymentResponseFeed") { + + val feedTypes = setOf(FeedType.WALLET_CONNECT) + + private fun createWalletConnectServiceWatcher(): TypedFilter { + // downloads all the reactions to a given event. + return TypedFilter( + types = feedTypes, + filter = JsonFilter( + kinds = listOf(LnZapPaymentResponseEvent.kind), + authors = listOf(fromServiceHex), + tags = mapOf( + "e" to listOf(replyingToHex), + "p" to listOf(toUserHex) + ), + limit = 1 + ) + ) + } + + val channel = requestNewChannel() + + override fun updateChannelFilters() { + val wc = createWalletConnectServiceWatcher() + + channel.typedFilters = listOfNotNull(wc).ifEmpty { null } + } +} 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 6d857328c..9b63fa799 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 @@ -225,6 +225,7 @@ open class Event( FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt index c35c75fda..e1d95ed87 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey @@ -16,6 +17,8 @@ class LnZapPaymentRequestEvent( sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) + fun lnInvoice(privKey: ByteArray): String? { return try { val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) @@ -37,9 +40,10 @@ class LnZapPaymentRequestEvent( createdAt: Long = Date().time / 1000 ): LnZapPaymentRequestEvent { val pubKey = Utils.pubkeyCreate(privateKey) + val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice)) val content = Utils.encrypt( - lnInvoice, + serializedRequest, privateKey, walletServicePubkey.toByteArray() ) @@ -53,3 +57,21 @@ class LnZapPaymentRequestEvent( } } } + +// REQUEST OBJECTS + +abstract class Request(val method: String, val params: Params) +abstract class Params + + +// PayInvoice Call + +class PayInvoiceMethod(bolt11: String): Request("pay_invoice", PayInvoiceParams(bolt11)) { + class PayInvoiceParams(val invoice: String): Params() +} + + + + + + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt new file mode 100644 index 000000000..1b6889878 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -0,0 +1,83 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toByteArray +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class LnZapPaymentResponseEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) + fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1) + + fun response(): Response? = try { + if (content.isNotEmpty()) { + gson.fromJson(content, Response::class.java) + } else { + null + } + } catch (e: Exception) { + Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) + null + } + + companion object { + const val kind = 23195 + } +} + +// RESPONSE OBJECTS +abstract class Response( + @SerializedName("result_type") + val resultType: String +) + +// PayInvoice Call + +class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams): + Response("pay_invoice") +{ + class PayInvoiceResultParams(val preimage: String) +} + +class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null): + Response("pay_invoice") +{ + class PayInvoiceErrorParams(val code: ErrorType?, val message: String?) + + enum class ErrorType { + @SerializedName(value = "rate_limited", alternate = ["RATE_LIMITED"]) + RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds. + @SerializedName(value = "not_implemented", alternate = ["NOT_IMPLEMENTED"]) + NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented. + @SerializedName(value = "insufficient_balance", alternate = ["INSUFFICIENT_BALANCE"]) + INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount. + @SerializedName(value = "quota_exceeded", alternate = ["QUOTA_EXCEEDED"]) + QUOTA_EXCEEDED, // The wallet has exceeded its spending quota. + @SerializedName(value = "restricted", alternate = ["RESTRICTED"]) + RESTRICTED, // This public key is not allowed to do this operation. + @SerializedName(value = "unauthorized", alternate = ["UNAUTHORIZED"]) + UNAUTHORIZED, // This public key has no wallet connected. + @SerializedName(value = "internal", alternate = ["INTERNAL"]) + INTERNAL, // An internal error. + @SerializedName(value = "other", alternate = ["OTHER"]) + OTHER, // Other error. + } +} + + + + + + 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 6f841deb6..ddcc98db1 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,11 +1,14 @@ package com.vitorpamplona.amethyst.service.relays +import com.vitorpamplona.amethyst.service.NostrDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.UUID /** @@ -64,22 +67,43 @@ object Client : RelayPool.Listener { RelayPool.sendFilterOnlyIfDisconnected() } - fun send(signedEvent: EventInterface, relay: String? = null) { + fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set? = null, onDone: (() -> Unit)? = null) { if (relay == null) { RelayPool.send(signedEvent) } else { - val useConnectedRelay = relays.filter { it.url == relay } + val useConnectedRelayIfPresent = relays.filter { it.url == relay } - if (useConnectedRelay.isNotEmpty()) { - useConnectedRelay.forEach { + if (useConnectedRelayIfPresent.isNotEmpty()) { + useConnectedRelayIfPresent.forEach { it.send(signedEvent) } } else { /** temporary connection */ - Relay(relay, false, true, emptySet()).requestAndWatch() { - it.send(signedEvent) - it.disconnect() - } + newSporadicRelay(relay, feedTypes, + onConnected = { relay -> + relay.send(signedEvent) + }, + onDone = onDone + ) + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + fun newSporadicRelay(url: String, feedTypes: Set?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) { + val relay = Relay(url, true, true, feedTypes ?: emptySet()) + RelayPool.addRelay(relay) + + relay.requestAndWatch { + onConnected(relay) + + GlobalScope.launch(Dispatchers.IO) { + delay(10000) // waits for a reply + relay.disconnect() + RelayPool.removeRelay(relay) + + if (onDone != null) + onDone() } } } 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 87407844b..279c1bbf0 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 @@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.service.relays import android.util.Log import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface +import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -13,7 +15,7 @@ import okhttp3.WebSocketListener import java.util.Date enum class FeedType { - FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH + FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT } class Relay( @@ -93,11 +95,18 @@ class Relay( val type = msg[0].asString val channel = msg[1].asString + Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}") + when (type) { "EVENT" -> { + val event = Event.fromJson(msg[2], Client.lenient) + if (event.kind == LnZapPaymentResponseEvent.kind) { + println("This " + event.toJson()) + } + // Log.w("Relay", "Relay onEVENT $url, $channel") listeners.forEach { - it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient)) + it.onEvent(this@Relay, channel, event) if (afterEOSE) { it.onRelayStateChange(this@Relay, Type.EOSE, channel) } @@ -109,15 +118,15 @@ class Relay( it.onRelayStateChange(this@Relay, Type.EOSE, channel) } "NOTICE" -> listeners.forEach { - // Log.w("Relay", "Relay onNotice $url, $channel") + Log.w("Relay", "Relay onNotice $url, $channel") it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - // Log.w("Relay", "Relay onOK $url, $channel") + Log.w("Relay", "Relay on OK $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, @@ -213,6 +222,26 @@ class Relay( } } + fun sendUnregisteredFilter(request: JsonFilter, subscriptionId: String?) { + if (read) { + if (isConnected()) { + if (isReady) { + val request = """["REQ","${subscriptionId ?: ""}",${request.toJson(url)}]""" + println("FILTERSSENT $url $request") + socket?.send(request) + eventUploadCounterInBytes += request.bytesUsedInMemory() + afterEOSE = false + } + } else { + // waits 60 seconds to reconnect after disconnected. + if (Date().time / 1000 > closingTime + 60) { + // sends all filters after connection is successful. + requestAndWatch() + } + } + } + } + fun sendFilterOnlyIfDisconnected() { if (socket == null) { // waits 60 seconds to reconnect after disconnected. 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 c88352b6c..6f3a89df8 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 @@ -15,6 +15,7 @@ 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.LnZapEvent +import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse import com.vitorpamplona.amethyst.service.model.ReportEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -80,7 +81,22 @@ class AccountViewModel(private val account: Account) : ViewModel() { onSuccess = { onProgress(0.7f) if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) + account.sendZapPaymentRequestFor( + bolt11 = it, + onResponse = { + val response = it.response() + if (response is PayInvoiceErrorResponse) { + onProgress(0.0f) + onError( + response.error?.message + ?: response.error?.code?.toString() + ?: "Error parsing error message" + ) + } else { + // awaits for confirmation from Receiver or timeout. + } + }, + ) onProgress(0.8f) // Awaits for the event to come back to LocalCache. 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 4d9bf93cc..040063733 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 @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.Intent import android.net.Uri +import android.widget.Toast import androidx.compose.foundation.* import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.* @@ -59,6 +60,9 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.IdentityClaim +import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent +import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse +import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus @@ -420,6 +424,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode val uri = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current val context = LocalContext.current + val scope = rememberCoroutineScope() Row(verticalAlignment = Alignment.Bottom) { user.bestDisplayName()?.let { @@ -561,7 +566,28 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode onSuccess = { // pay directly if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) + account.sendZapPaymentRequestFor(it) { + val response = it.response() + if (response is PayInvoiceSuccessResponse) { + scope.launch { + Toast.makeText( + context, + "Payment Successful", // Turn this into a UI animation + Toast.LENGTH_LONG + ).show() + } + } else if (response is PayInvoiceErrorResponse) { + scope.launch { + Toast.makeText( + context, + response.error?.message + ?: response.error?.code?.toString() + ?: "Error parsing error message", + Toast.LENGTH_LONG + ).show() + } + } + } } else { runCatching { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) From b3f8b06feaa05856a163db7ede8530c41bda9b60 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 23 Apr 2023 16:10:20 -0400 Subject: [PATCH 049/164] formatting improvements --- .../vitorpamplona/amethyst/model/Account.kt | 2 -- .../NostrLnZapPaymentResponseDataSource.kt | 4 ++-- .../service/model/LnZapPaymentRequestEvent.kt | 12 ++-------- .../model/LnZapPaymentResponseEvent.kt | 23 ++++--------------- .../amethyst/service/relays/Client.kt | 9 ++++---- .../amethyst/service/relays/Relay.kt | 1 - .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../ui/screen/loggedIn/ProfileScreen.kt | 1 - 8 files changed, 15 insertions(+), 39 deletions(-) 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 f548d9605..2ee90218b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -4,13 +4,11 @@ import android.content.res.Resources import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.FileHeader -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.model.* 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.JsonFilter import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.ui.components.BundledUpdate diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt index faa301cce..57de8be6e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt @@ -8,8 +8,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter class NostrLnZapPaymentResponseDataSource( private var fromServiceHex: String, private var toUserHex: String, - private var replyingToHex: String, -): NostrDataSource("LnZapPaymentResponseFeed") { + private var replyingToHex: String +) : NostrDataSource("LnZapPaymentResponseFeed") { val feedTypes = setOf(FeedType.WALLET_CONNECT) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt index e1d95ed87..ff21118f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -1,7 +1,6 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log -import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey @@ -63,15 +62,8 @@ class LnZapPaymentRequestEvent( abstract class Request(val method: String, val params: Params) abstract class Params - // PayInvoice Call -class PayInvoiceMethod(bolt11: String): Request("pay_invoice", PayInvoiceParams(bolt11)) { - class PayInvoiceParams(val invoice: String): Params() +class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) { + class PayInvoiceParams(val invoice: String) : Params() } - - - - - - diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt index 1b6889878..83718d310 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -2,12 +2,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.google.gson.annotations.SerializedName -import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.HexKey -import com.vitorpamplona.amethyst.model.toByteArray -import com.vitorpamplona.amethyst.model.toHexKey -import nostr.postr.Utils -import java.util.Date class LnZapPaymentResponseEvent( id: HexKey, @@ -45,15 +40,13 @@ abstract class Response( // PayInvoice Call -class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams): - Response("pay_invoice") -{ +class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) : + Response("pay_invoice") { class PayInvoiceResultParams(val preimage: String) } -class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null): - Response("pay_invoice") -{ +class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) : + Response("pay_invoice") { class PayInvoiceErrorParams(val code: ErrorType?, val message: String?) enum class ErrorType { @@ -72,12 +65,6 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null): @SerializedName(value = "internal", alternate = ["INTERNAL"]) INTERNAL, // An internal error. @SerializedName(value = "other", alternate = ["OTHER"]) - OTHER, // Other error. + OTHER // Other error. } } - - - - - - 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 ddcc98db1..c3af984c3 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,6 +1,5 @@ package com.vitorpamplona.amethyst.service.relays -import com.vitorpamplona.amethyst.service.NostrDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import kotlinx.coroutines.DelicateCoroutinesApi @@ -8,7 +7,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.UUID /** @@ -79,7 +77,9 @@ object Client : RelayPool.Listener { } } else { /** temporary connection */ - newSporadicRelay(relay, feedTypes, + newSporadicRelay( + relay, + feedTypes, onConnected = { relay -> relay.send(signedEvent) }, @@ -102,8 +102,9 @@ object Client : RelayPool.Listener { relay.disconnect() RelayPool.removeRelay(relay) - if (onDone != null) + if (onDone != null) { onDone() + } } } } 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 279c1bbf0..ca785f8fc 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 @@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.service.relays import android.util.Log import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent 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 6f3a89df8..5a2ac2cbd 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 @@ -95,7 +95,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { } else { // awaits for confirmation from Receiver or timeout. } - }, + } ) onProgress(0.8f) 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 040063733..91c6f7b4a 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 @@ -60,7 +60,6 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.IdentityClaim -import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse import com.vitorpamplona.amethyst.service.model.ReportEvent From cbc0f95498bfbd7ae704fb7dccae2cf217f0b4d4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 23 Apr 2023 16:32:44 -0400 Subject: [PATCH 050/164] Show previews of most notes, including NIP94 - images, on new posts. --- .../vitorpamplona/amethyst/ui/actions/NewPostView.kt | 12 +++++++++++- .../vitorpamplona/amethyst/ui/buttons/FabColumn.kt | 2 +- .../amethyst/ui/buttons/NewNoteButton.kt | 6 ++++-- .../vitorpamplona/amethyst/ui/note/NoteCompose.kt | 8 ++++---- .../vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 7 ++++--- .../amethyst/ui/screen/ThreadFeedView.kt | 2 +- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 8 ++++---- 7 files changed, 29 insertions(+), 16 deletions(-) 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 35210be77..e5068b3d5 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account @@ -44,13 +45,14 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.note.ReplyInformation +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.delay @OptIn(ExperimentalComposeUiApi::class) @Composable -fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) { +fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, navController: NavController) { val postViewModel: NewPostViewModel = viewModel() val context = LocalContext.current @@ -234,6 +236,14 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } else { UrlPreview(myUrlPreview, myUrlPreview) } + } else if (isBechLink(myUrlPreview)) { + BechLink( + myUrlPreview, + true, + MaterialTheme.colors.background, + accountViewModel, + navController + ) } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { UrlPreview("https://$myUrlPreview", myUrlPreview) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt index 1a0715ef2..a1822d009 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt @@ -88,7 +88,7 @@ fun FabColumn(account: Account) { } if (wantsToPost) { - NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) + //NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) } if (wantsToPoll) { 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 ece9c89e9..0fe83a465 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 @@ -16,18 +16,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.NewPostView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun NewNoteButton(account: Account) { +fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, navController: NavController) { var wantsToPost by remember { mutableStateOf(false) } if (wantsToPost) { - NewPostView({ wantsToPost = false }, account = account) + NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, navController = navController) } OutlinedButton( 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 bbcf20e2b..b8bde729b 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 @@ -450,7 +450,7 @@ fun NoteComposeInner( } else if (noteEvent is LongTextNoteEvent) { LongFormHeader(noteEvent, note, loggedIn) - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) Divider( modifier = Modifier.padding(top = 10.dp), @@ -485,7 +485,7 @@ fun NoteComposeInner( ) } - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) Divider( modifier = Modifier.padding(top = 10.dp), @@ -512,7 +512,7 @@ fun NoteComposeInner( ) if (!makeItShort) { - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) } Divider( @@ -556,7 +556,7 @@ fun NoteComposeInner( } if (!makeItShort) { - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) } Divider( 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 cf9bc9845..27234a2b9 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 @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup +import androidx.navigation.NavController import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest @@ -66,7 +67,7 @@ import java.math.RoundingMode import kotlin.math.roundToInt @Composable -fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { +fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -79,11 +80,11 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { } if (wantsToReplyTo != null) { - NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account) + NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, navController) } if (wantsToQuote != null) { - NewPostView({ wantsToQuote = null }, null, wantsToQuote, account) + NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController) } Spacer(modifier = Modifier.height(8.dp)) 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 26d0448be..2b3a6b466 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 @@ -378,7 +378,7 @@ fun NoteMaster( } } - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) Divider( modifier = Modifier.padding(top = 10.dp), 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 49486609d..0adfec2b0 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 @@ -73,7 +73,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun } }, floatingActionButton = { - FloatingButtons(navController, accountStateViewModel) + FloatingButtons(navController, accountViewModel, accountStateViewModel) }, scaffoldState = scaffoldState ) { @@ -85,8 +85,8 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun } @Composable -fun FloatingButtons(navController: NavHostController, accountViewModel: AccountStateViewModel) { - val accountState by accountViewModel.accountContent.collectAsState() +fun FloatingButtons(navController: NavHostController, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) { + val accountState by accountStateViewModel.accountContent.collectAsState() if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> @@ -98,7 +98,7 @@ fun FloatingButtons(navController: NavHostController, accountViewModel: AccountS // Does nothing. } is AccountState.LoggedIn -> { - NewNoteButton(state.account) + NewNoteButton(state.account, accountViewModel, navController) } } } From f1affc2dbbe2cd913b7abbc34d38609ec06bc929 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 23 Apr 2023 16:32:50 -0400 Subject: [PATCH 051/164] Formatting --- .../java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt index a1822d009..771b5f658 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt @@ -13,9 +13,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.ui.actions.NewPollView -import com.vitorpamplona.amethyst.ui.actions.NewPostView @Composable fun FabColumn(account: Account) { @@ -88,7 +86,7 @@ fun FabColumn(account: Account) { } if (wantsToPost) { - //NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) + // NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) } if (wantsToPoll) { From a13dbf2bda66b0ab2a94332feb9711b295febc38 Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sun, 23 Apr 2023 22:48:53 +0200 Subject: [PATCH 052/164] decode private zaps and show in notifications + show zap messages --- .../amethyst/model/LocalCache.kt | 11 ---- .../amethyst/service/model/Event.kt | 2 +- .../amethyst/ui/note/MultiSetCompose.kt | 64 +++++++++++++++---- .../amethyst/ui/note/ZapCustomDialog.kt | 10 ++- app/src/main/res/values/strings.xml | 3 + 5 files changed, 66 insertions(+), 24 deletions(-) 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 140a30849..640318b2f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -594,17 +594,6 @@ object LocalCache { fun consume(event: LnZapEvent) { val note = getOrCreateNote(event.id) - - var decryptedContent = LnZapRequestEvent.checkForPrivateZap(event.zapRequest!!, account.loggedIn.privKey!!) - if (decryptedContent != null) { - Log.e( - "DC", - "Decrypted Content from Anon Tag: Sender: {${decryptedContent.pubKey}}, Message: {${decryptedContent.content}} " - - // TODO Update Notification with this Sender and Message - ) - } - // Already processed this event. if (note.event != null) return 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 6d857328c..f5924fcd5 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 @@ -19,7 +19,7 @@ open class Event( @SerializedName("created_at") val createdAt: Long, val kind: Int, val tags: List>, - val content: String, + var content: String, val sig: HexKey ) : EventInterface { override fun id(): HexKey = id 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 537649905..5148c0632 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 @@ -36,8 +36,13 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +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.PrivateDmEvent +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -135,7 +140,16 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - AuthorGallery(multiSetCard.zapEvents.keys, navController, account) + for (i in multiSetCard.zapEvents) { + var decryptedContent = (i.value.event as LnZapEvent).zapRequest?.let { + LnZapRequestEvent.checkForPrivateZap(it, NostrAccountDataSource.account.loggedIn.privKey!!) + } + if (decryptedContent != null) { + (i.key.event as Event).content = decryptedContent.content + i.key.author = LocalCache.getOrCreateUser(decryptedContent.pubKey) + } + } + AuthorGallery(multiSetCard.zapEvents.keys, navController, account, accountViewModel, "zap") } } @@ -156,7 +170,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - AuthorGallery(multiSetCard.boostEvents, navController, account) + AuthorGallery(multiSetCard.boostEvents, navController, account, accountViewModel) } } @@ -177,7 +191,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - AuthorGallery(multiSetCard.likeEvents, navController, account) + AuthorGallery(multiSetCard.likeEvents, navController, account, accountViewModel) } } @@ -211,7 +225,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun fun AuthorGallery( authorNotes: Collection, navController: NavController, - account: Account + account: Account, + accountViewModel: AccountViewModel, + kind: String = "nonzap" ) { val accountState by account.userProfile().live().follows.observeAsState() val accountUser = accountState?.user ?: return @@ -219,12 +235,39 @@ fun AuthorGallery( Column(modifier = Modifier.padding(start = 10.dp)) { FlowRow() { authorNotes.forEach { - FastNoteAuthorPicture( - note = it, - navController = navController, - userAccount = accountUser, - size = 35.dp - ) + if (it.event?.content() != "" && kind == "zap") { + Row(Modifier.fillMaxWidth()) { + FastNoteAuthorPicture( + note = it, + navController = navController, + userAccount = accountUser, + size = 35.dp + ) + } + } else { + Row() { + FastNoteAuthorPicture( + note = it, + navController = navController, + userAccount = accountUser, + size = 35.dp + ) + } + } + if (it.event?.content() != "" && kind == "zap") { + Row(Modifier.fillMaxWidth()) { + it.event?.let { + TranslatableRichTextViewer( + content = it.content(), + canPreview = true, + tags = null, + backgroundColor = MaterialTheme.colors.background, + accountViewModel = accountViewModel, + navController = navController + ) + } + } + } } } } @@ -245,7 +288,6 @@ fun FastNoteAuthorPicture( val user = userState?.user ?: return val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount - UserPicture( userHex = user.pubkeyHex, userPicture = user.profilePicture(), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 521d52486..7ea074c96 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -164,7 +164,15 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc ) { OutlinedTextField( // stringResource(R.string.new_amount_in_sats - label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) }, + label = { + if (selectedZapType == LnZapEvent.ZapType.PUBLIC || selectedZapType == LnZapEvent.ZapType.ANONYMOUS) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) + } else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message_private)) + } else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message_nonzap)) + } + }, value = postViewModel.customMessage, onValueChange = { postViewModel.customMessage = it diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e3109e95..3087f1fc8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,6 +287,9 @@ "Looking for Event %1$s" Add a public message + Add a private message + Add an invoice message + Thank you for all your work! Create and Add From 655cd20a01fcfedf830d15578b0a618392103858 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 10:21:01 -0400 Subject: [PATCH 053/164] Adds support for video and image descriptions for accessibility use cases --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 1 + .../amethyst/service/FileHeader.kt | 4 +- .../amethyst/ui/actions/NewPostView.kt | 175 +++++++++++++++++- .../amethyst/ui/actions/NewPostViewModel.kt | 85 +++++---- .../amethyst/ui/components/VideoView.kt | 23 ++- .../ui/components/ZoomableContentView.kt | 4 +- .../ui/screen/loggedIn/ChannelScreen.kt | 2 +- .../ui/screen/loggedIn/ChatroomScreen.kt | 2 +- app/src/main/res/values/strings.xml | 7 + 10 files changed, 260 insertions(+), 45 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5a206becf..5e5be2e08 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -137,7 +137,7 @@ dependencies { implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05' // view videos - implementation 'com.google.android.exoplayer:exoplayer:2.18.5' + implementation 'com.google.android.exoplayer:exoplayer:2.18.6' // Load images from the web. implementation "io.coil-kt:coil-compose:$coil_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 724d0252e..b7becbfa0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index a7543e56f..c4e6ff8ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -18,7 +18,7 @@ class FileHeader( val description: String? = null ) { companion object { - fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { + fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { try { val imageData = URL(fileUrl).readBytes() val sha256 = MessageDigest.getInstance("SHA-256") @@ -55,7 +55,7 @@ class FileHeader( null } - onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, "")) + onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description)) } catch (e: Exception) { Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") onError() 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 e5068b3d5..37904090f 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 @@ -1,5 +1,9 @@ package com.vitorpamplona.amethyst.ui.actions +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.util.Size import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -13,10 +17,15 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -25,15 +34,18 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection 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 androidx.lifecycle.viewmodel.compose.viewModel @@ -48,7 +60,9 @@ import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -188,6 +202,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } + val url = postViewModel.contentToAddUrl + if (url != null) { + ImageVideoDescription( + url, + onAdd = { description -> + postViewModel.upload(url, description, context) + }, + onCancel = { + postViewModel.contentToAddUrl = null + } + ) + } + val user = postViewModel.account?.userProfile() val lud16 = user?.info?.lnAddress() @@ -277,7 +304,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n tint = MaterialTheme.colors.onBackground, modifier = Modifier.padding(bottom = 10.dp) ) { - postViewModel.upload(it, context) + postViewModel.selectImage(it) } if (postViewModel.canUsePoll) { @@ -457,3 +484,149 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier ) } } + +@Composable +fun ImageVideoDescription( + uri: Uri, + onAdd: (String) -> Unit, + onCancel: () -> Unit +) { + val resolver = LocalContext.current.contentResolver + val mediaType = resolver.getType(uri) ?: "" + val scope = rememberCoroutineScope() + + val isImage = mediaType.startsWith("image") + val isVideo = mediaType.startsWith("video") + + 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) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(30.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + Text( + text = stringResource( + if (isImage) { + R.string.content_description_add_image + } else { + if (isVideo) { + R.string.content_description_add_video + } else { + R.string.content_description_add_document + } + } + ), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier + .padding(start = 10.dp) + .weight(1.0f) + ) + + IconButton( + modifier = Modifier.size(30.dp), + onClick = onCancel + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier + .padding(end = 5.dp) + .size(30.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + if (mediaType.startsWith("image")) { + AsyncImage( + model = uri.toString(), + contentDescription = uri.toString(), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + ) + } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var bitmap by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = uri) { + scope.launch(Dispatchers.IO) { + bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) + } + } + + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + ) + } + } else { + VideoView(uri) + } + } + + var message by remember { mutableStateOf("") } + + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ) + ) + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + onClick = { + onAdd(message) + }, + shape = RoundedCornerShape(15.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp) + } + } + } +} 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 5fccd7a43..600d60df1 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 @@ -38,6 +38,9 @@ open class NewPostViewModel : ViewModel() { var userSuggestions by mutableStateOf>(emptyList()) var userSuggestionAnchor: TextRange? = null + // Images and Videos + var contentToAddUrl by mutableStateOf(null) + // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) @@ -84,6 +87,7 @@ open class NewPostViewModel : ViewModel() { canAddInvoice = account.userProfile().info?.lnAddress() != null canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null + contentToAddUrl = null this.account = account } @@ -105,46 +109,15 @@ open class NewPostViewModel : ViewModel() { cancel() } - fun upload(it: Uri, context: Context) { + fun upload(it: Uri, description: String, context: Context) { isUploadingImage = true + contentToAddUrl = null ImageUploader.uploadImage( uri = it, contentResolver = context.contentResolver, onSuccess = { imageUrl, mimeType -> - viewModelScope.launch(Dispatchers.IO) { - // Images don't seem to be ready immediately after upload - - if (mimeType?.startsWith("image/") == true) { - delay(2000) - } else { - delay(5000) - } - - FileHeader.prepare( - imageUrl, - mimeType, - onReady = { - val note = account?.sendHeader(it) - - isUploadingImage = false - - if (note == null) { - message = TextFieldValue(message.text + "\n\n" + imageUrl) - } else { - message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) - } - - urlPreview = findUrlInMessage() - }, - onError = { - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } - } - ) - } + createNIP97Record(imageUrl, mimeType, description) }, onError = { isUploadingImage = false @@ -157,6 +130,7 @@ open class NewPostViewModel : ViewModel() { open fun cancel() { message = TextFieldValue("") + contentToAddUrl = null urlPreview = null isUploadingImage = false mentions = null @@ -220,7 +194,7 @@ open class NewPostViewModel : ViewModel() { fun canPost(): Boolean { return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && - (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) + (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null } fun includePollHashtagInMessage(include: Boolean, hashtag: String) { @@ -235,4 +209,45 @@ open class NewPostViewModel : ViewModel() { ) } } + + fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) { + viewModelScope.launch(Dispatchers.IO) { + // Images don't seem to be ready immediately after upload + + if (mimeType?.startsWith("image/") == true) { + delay(2000) + } else { + delay(5000) + } + + FileHeader.prepare( + imageUrl, + mimeType, + description, + onReady = { + val note = account?.sendHeader(it) + + isUploadingImage = false + + if (note == null) { + message = TextFieldValue(message.text + "\n\n" + imageUrl) + } else { + message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) + } + + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + + fun selectImage(uri: Uri) { + contentToAddUrl = uri + } } 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 ab67d171d..21b0efb53 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.components +import android.net.Uri import android.view.ViewGroup import android.widget.FrameLayout import androidx.compose.foundation.layout.fillMaxWidth @@ -16,6 +17,7 @@ import androidx.lifecycle.LifecycleEventObserver import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.ui.AspectRatioFrameLayout @@ -23,16 +25,33 @@ import com.google.android.exoplayer2.ui.StyledPlayerView import com.vitorpamplona.amethyst.VideoCache @Composable -fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) { +fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { + VideoView(Uri.parse(videoUri), description, onDialog) +} + +@Composable +fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { val context = LocalContext.current val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) val exoPlayer = remember(videoUri) { + val mediaBuilder = MediaItem.Builder().setUri(videoUri) + + description?.let { + mediaBuilder.setMediaMetadata( + MediaMetadata.Builder().setDisplayTitle(it).build() + ) + } + + val media = mediaBuilder.build() + ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING setMediaSource( - ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri)) + ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource( + media + ) ) prepare() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 69b053577..8f8c15cfa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -175,7 +175,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List } } } else { - VideoView(content.url) { dialogOpen = true } + VideoView(content.url, content.description) { dialogOpen = true } } if (dialogOpen) { @@ -322,7 +322,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) { } } else { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - VideoView(content.url) + VideoView(content.url, content.description) } } } 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 784d61262..fe3602a31 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 @@ -233,7 +233,7 @@ fun ChannelScreen( tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - channelScreenModel.upload(it, context) + channelScreenModel.upload(it, "", context) } }, colors = TextFieldDefaults.textFieldColors( 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 07381c5e7..5dd3bb2ca 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 @@ -194,7 +194,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - chatRoomScreenModel.upload(it, context) + chatRoomScreenModel.upload(it, "", context) } }, colors = TextFieldDefaults.textFieldColors( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e3109e95..504fcfae0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,4 +297,11 @@ Image is the same since the post Image has changed. The author might not have seen the change + Add Image + Add Video + Add Document + Create and Add + Description of the contents + A blue boat in a white sandy beach at sunset + From b4373ae1ed86f21e546c170db2a607cfdea73b00 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 17:58:35 -0400 Subject: [PATCH 054/164] Deprecating #[]s tags --- .../com/vitorpamplona/amethyst/model/Hex.kt | 8 +- .../com/vitorpamplona/amethyst/model/Note.kt | 7 ++ .../com/vitorpamplona/amethyst/model/User.kt | 1 + .../service/model/BaseTextNoteEvent.kt | 53 ++++++++++++++ .../amethyst/service/nip19/Nip19.kt | 73 ++++++++++++++++--- .../amethyst/ui/actions/NewMessageTagger.kt | 8 +- .../amethyst/ui/actions/NewPostViewModel.kt | 4 +- .../amethyst/ui/note/NoteCompose.kt | 4 +- .../amethyst/ui/note/NoteQuickActionMenu.kt | 6 +- .../vitorpamplona/amethyst/NIP19ParserTest.kt | 71 ++++++++++++++++++ 10 files changed, 211 insertions(+), 24 deletions(-) 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 7e16f77d8..eed0507d5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt @@ -84,15 +84,15 @@ fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { } else if (key.startsWith("nprofile", true)) { val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null - return DirtyKeyInfo(pubkeyRelay, restOfWord) - } else if (key.startsWith("nevent", true)) { + return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars) + } else if (key.startsWith("nevent1", true)) { val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null - return DirtyKeyInfo(noteRelayId, restOfWord) + return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars) } else if (key.startsWith("naddr1", true)) { val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null - return DirtyKeyInfo(address, "") // no way to know when they address ends and dirt begins + return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins } } catch (e: Exception) { e.printStackTrace() 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 7bf4537f7..0b145008f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.EOSETime import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledUpdate @@ -20,6 +21,7 @@ val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]") class AddressableNote(val address: ATag) : Note(address.toTag()) { override fun idNote() = address.toNAddr() + override fun toNEvent() = address.toNAddr() override fun idDisplayNote() = idNote().toShortenHex() override fun address() = address override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt() @@ -51,6 +53,11 @@ open class Note(val idHex: String) { fun id() = Hex.decode(idHex) open fun idNote() = id().toNote() + + open fun toNEvent(): String { + return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()) + } + open fun idDisplayNote() = idNote().toShortenHex() fun channelHex(): HexKey? { 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 9696a5795..13a0cdd93 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -47,6 +47,7 @@ class User(val pubkeyHex: String) { fun pubkey() = Hex.decode(pubkeyHex) fun pubkeyNpub() = pubkey().toNpub() + fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() override fun toString(): String = pubkeyHex 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 031c2b1cf..bcd47faee 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 @@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.tagSearch +import com.vitorpamplona.amethyst.service.nip19.Nip19 +import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex open class BaseTextNoteEvent( id: HexKey, @@ -31,6 +33,28 @@ open class BaseTextNoteEvent( } catch (e: Exception) { } } + + val matcher2 = nip19regex.matcher(content) + while (matcher2.find()) { + val uriScheme = matcher2.group(1) // nostr: + val type = matcher2.group(2) // npub1 + val key = matcher2.group(3) // bech32 + val additionalChars = matcher2.group(4) // additional chars + + val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + + if (parsed != null) { + try { + val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } + + if (tag != null && tag[0] == "p") { + returningList.add(tag[1]) + } + } catch (e: Exception) { + } + } + } + citedUsersCache = returningList return returningList } @@ -51,6 +75,35 @@ open class BaseTextNoteEvent( } catch (e: Exception) { } } + + val matcher2 = nip19regex.matcher(content) + while (matcher2.find()) { + val uriScheme = matcher2.group(1) // nostr: + val type = matcher2.group(2) // npub1 + val key = matcher2.group(3) // bech32 + val additionalChars = matcher2.group(4) // additional chars + + val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + + if (parsed != null) { + if (content.contains("Testing event")) { + println("AAAA $key") + } + + try { + val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } + + if (tag != null && tag[0] == "e") { + citations.add(tag[1]) + } + if (tag != null && tag[0] == "a") { + citations.add(tag[1]) + } + } catch (e: Exception) { + } + } + } + return citations } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt index 87be07093..6aa8dd754 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt @@ -1,8 +1,11 @@ package com.vitorpamplona.amethyst.service.nip19 import android.util.Log +import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Bech32 import nostr.postr.bechToBytes +import nostr.postr.toByteArray import java.util.regex.Pattern object Nip19 { @@ -12,7 +15,14 @@ object Nip19 { val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE) - data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "") + data class Return( + val type: Type, + val hex: String, + val relay: String? = null, + val author: String? = null, + val kind: Long? = null, + val additionalChars: String = "" + ) fun uriToRoute(uri: String?): Return? { if (uri == null) return null @@ -26,8 +36,23 @@ object Nip19 { val uriScheme = matcher.group(1) // nostr: val type = matcher.group(2) // npub1 val key = matcher.group(3) // bech32 - val additionalChars = matcher.group(4) ?: "" // additional chars + val additionalChars = matcher.group(4) // additional chars + return parseComponents(uriScheme, type, key, additionalChars) + } catch (e: Throwable) { + Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) + } + + return null + } + + fun parseComponents( + uriScheme: String?, + type: String, + key: String?, + additionalChars: String? + ): Return? { + return try { val bytes = (type + key).bechToBytes() val parsed = when (type.lowercase()) { "npub1" -> npub(bytes) @@ -38,12 +63,11 @@ object Nip19 { "naddr1" -> naddr(bytes) else -> null } - return parsed?.copy(additionalChars = additionalChars) + parsed?.copy(additionalChars = additionalChars ?: "") } catch (e: Throwable) { - Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) + Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) + null } - - return null } private fun npub(bytes: ByteArray): Return { @@ -79,7 +103,15 @@ object Nip19 { ?.get(0) ?.toString(Charsets.UTF_8) - return Return(Type.EVENT, hex, relay) + val author = tlv.get(Tlv.Type.AUTHOR.id) + ?.get(0) + ?.toHexKey() + + val kind = tlv.get(Tlv.Type.KIND.id) + ?.get(0) + ?.let { Tlv.toInt32(it) }?.toLong() + + return Return(Type.EVENT, hex, relay, author, kind) } private fun nrelay(bytes: ByteArray): Return? { @@ -108,8 +140,31 @@ object Nip19 { val kind = tlv.get(Tlv.Type.KIND.id) ?.get(0) - ?.let { Tlv.toInt32(it) } + ?.let { Tlv.toInt32(it) }?.toLong() - return Return(Type.ADDRESS, "$kind:$author:$d", relay) + return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) + } + + public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String { + val kind = kind?.toByteArray() + val author = author?.toByteArray() + val idHex = idHex.toByteArray() + val relay = relay?.toByteArray(Charsets.UTF_8) + + var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex + + if (relay != null) { + fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay + } + + if (author != null) { + fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author + } + + if (kind != null) { + fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind + } + + return Bech32.encodeBytes(hrp = "nevent", fullArray, Bech32.Encoding.Bech32) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index b0d6428f4..55b5e04e8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -56,19 +56,19 @@ class NewMessageTagger(var channel: Channel?, var mentions: List?, var rep if (results?.key?.type == Nip19.Type.USER) { val user = LocalCache.getOrCreateUser(results.key.hex) - "#[${tagIndex(user)}]${results.restOfWord}" + "nostr:${user.pubkeyNpub()}${results.restOfWord}" } else if (results?.key?.type == Nip19.Type.NOTE) { val note = LocalCache.getOrCreateNote(results.key.hex) - "#[${tagIndex(note)}]${results.restOfWord}" + "nostr:${note.toNEvent()}${results.restOfWord}" } else if (results?.key?.type == Nip19.Type.EVENT) { val note = LocalCache.getOrCreateNote(results.key.hex) - "#[${tagIndex(note)}]${results.restOfWord}" + "nostr:${note.toNEvent()}${results.restOfWord}" } else if (results?.key?.type == Nip19.Type.ADDRESS) { val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) if (note != null) { - "#[${tagIndex(note)}]${results.restOfWord}" + "nostr:${note.idNote()}${results.restOfWord}" } else { word } 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 600d60df1..a7f07605f 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 @@ -82,7 +82,7 @@ open class NewPostViewModel : ViewModel() { } quote?.let { - message = TextFieldValue(message.text + "\n\n@${it.idNote()}") + message = TextFieldValue(message.text + "\n\n@${it.toNEvent()}") } canAddInvoice = account.userProfile().info?.lnAddress() != null @@ -232,7 +232,7 @@ open class NewPostViewModel : ViewModel() { if (note == null) { message = TextFieldValue(message.text + "\n\n" + imageUrl) } else { - message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) + message = TextFieldValue(message.text + "\n\nnostr:" + note.toNEvent()) } urlPreview = findUrlInMessage() 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 b8bde729b..352775a3c 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 @@ -1196,10 +1196,10 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) { Text(stringResource(R.string.copy_text)) } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}")); onDismiss() }) { + DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")); onDismiss() }) { Text(stringResource(R.string.copy_user_pubkey)) } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); onDismiss() }) { + DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())); onDismiss() }) { Text(stringResource(R.string.copy_note_id)) } DropdownMenuItem(onClick = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index ca98d7048..e5271bc0f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -85,7 +85,7 @@ val externalLinkForNote = { note: Note -> "https://habla.news/a/${note.address().toNAddr()}" } } else { - "https://snort.social/e/${note.idNote()}" + "https://snort.social/e/${note.toNEvent()}" } } @@ -152,13 +152,13 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni } VerticalDivider(primaryLight) NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) { - clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}")) + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) showToast(R.string.copied_user_id_to_clipboard) onDismiss() } VerticalDivider(primaryLight) NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) { - clipboardManager.setText(AnnotatedString("@${note.idNote()}")) + clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) showToast(R.string.copied_note_id_to_clipboard) onDismiss() } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt index 488cb27d2..cbd9950b8 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt @@ -54,12 +54,24 @@ class NIP19ParserTest { assertEquals("naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", address.toNAddr()) } + @Test + fun nAddrParserPablo() { + val result = Nip19.uriToRoute("naddr1qq2hs7p30p6kcunxxamkgcnyd33xxve3veshyq3qyujphdcz69z6jafxpnldae3xtymdekfeatkt3r4qusr3w5krqspqxpqqqpaxjlg805f") + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals("31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", result?.hex) + assertEquals(null, result?.relay) + assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) + assertEquals(31337L, result?.kind) + } + @Test fun nAddrParserGizmo() { val result = Nip19.uriToRoute("naddr1qpqrvvfnvccrzdryxgunzvtxvgukge34xfjnqdpcv9sk2desxgmrscesvserzd3h8ycrywphvg6nsvf58ycnqef3v5mnsvt98pjnqdfs8ypzq3huhccxt6h34eupz3jeynjgjgek8lel2f4adaea0svyk94a3njdqvzqqqr4gudhrkyk") assertEquals(Nip19.Type.ADDRESS, result?.type) assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", result?.hex) assertEquals(null, result?.relay) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) + assertEquals(30023L, result?.kind) } @Test @@ -68,6 +80,8 @@ class NIP19ParserTest { assertEquals(Nip19.Type.ADDRESS, result?.type) assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex) assertEquals(null, result?.relay) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) + assertEquals(30023L, result?.kind) } @Test @@ -76,5 +90,62 @@ class NIP19ParserTest { assertEquals(Nip19.Type.EVENT, result?.type) assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex) assertEquals(null, result?.relay) + assertEquals(null, result?.author) + assertEquals(null, result?.kind) + } + + @Test + fun nEventParser() { + val result = Nip19.uriToRoute("nostr:nevent1qqstvrl6wftd8ht4g0vrp6m30tjs6pdxcvk977g769dcvlptkzu4ftqppamhxue69uhkummnw3ezumt0d5pzp78lz8r60568sd2a8dx3wnj6gume02gxaf92vx4fk67qv5kpagt6qvzqqqqqqygqr86c") + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) + assertEquals("wss://nostr.mom", result?.relay) + assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) + assertEquals(1L, result?.kind) + } + + @Test + fun nEventParser2() { + val result = Nip19.uriToRoute("nostr:nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w") + + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) + assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) + assertEquals(1L, result?.kind) + } + + @Test + fun nEventParserInvalidChecksum() { + val result = Nip19.uriToRoute("nostr:nevent1qqsyxq8v0730nz38dupnjzp5jegkyz4gu2ptwcps4v32hjnrap0q0espz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyn3t9gj") + + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) + assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) + assertEquals(1L, result?.kind) + } + + @Test + fun nEventFormatter() { + val nevent = Nip19.createNEvent("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", null, null, null) + assertEquals("nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", nevent) + } + + @Test + fun nEventFormatterWithExtraInfo() { + val nevent = Nip19.createNEvent("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", 40, null) + assertEquals("nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhqzypl62m6ad932k83u6sjwwkxrqq4cve0hkrvdem5la83g34m4rtqegqcyqqqqq2qh26va4", nevent) + } + + @Test + fun nEventFormatterWithFullInfo() { + val nevent = Nip19.createNEvent( + "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + 1, + "wss://relay.damus.io" + ) + assertEquals("nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", nevent) } } From f8319c720a7a0ee994fe553ed14131b1f0da85b7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 18:51:57 -0400 Subject: [PATCH 055/164] Support for the new JSON-based Wallet Connect API --- .../vitorpamplona/amethyst/model/Account.kt | 19 ++++++---- .../service/NostrAccountDataSource.kt | 14 ++++---- .../service/NostrChatroomListDataSource.kt | 5 +-- .../service/NostrHashtagDataSource.kt | 4 +-- .../NostrSearchEventOrUserDataSource.kt | 10 +++--- .../service/NostrSingleChannelDataSource.kt | 3 +- .../service/NostrSingleEventDataSource.kt | 10 +++--- .../service/NostrSingleUserDataSource.kt | 6 ++-- .../amethyst/service/NostrThreadDataSource.kt | 4 +-- .../service/NostrUserProfileDataSource.kt | 18 +++++----- .../model/LnZapPaymentResponseEvent.kt | 22 ++++++++++-- .../amethyst/service/relays/Client.kt | 4 +++ .../amethyst/service/relays/Relay.kt | 36 ++++--------------- .../ui/screen/loggedIn/AccountViewModel.kt | 5 ++- .../ui/screen/loggedIn/ProfileScreen.kt | 3 +- 15 files changed, 85 insertions(+), 78 deletions(-) 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 0a7bded58..38e78fca5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -167,17 +167,24 @@ class Account( return zapPaymentRequest != null } - fun sendZapPaymentRequestFor(bolt11: String, onResponse: (LnZapPaymentResponseEvent) -> Unit) { + fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) { if (!isWriteable()) return - zapPaymentRequest?.let { - val event = LnZapPaymentRequestEvent.create(bolt11, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) + zapPaymentRequest?.let { nip47 -> + val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!) - val wcListener = NostrLnZapPaymentResponseDataSource(it.pubKeyHex, loggedIn.pubKey.toHexKey(), event.id) + val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id) wcListener.start() - LocalCache.consume(event, onResponse) - Client.send(event, it.relayUri, wcListener.feedTypes) { + LocalCache.consume(event) { + // After the response is received. + val privKey = nip47.secret?.toByteArray() + if (privKey != null) { + onResponse(it.response(privKey, event.pubKey.toByteArray())) + } + } + + Client.send(event, nip47.relayUri, wcListener.feedTypes) { wcListener.destroy() } } 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 1d2327426..a3546f003 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -13,8 +13,8 @@ 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.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -25,7 +25,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountContactListFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ContactListEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -36,7 +36,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountMetadataFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -47,7 +47,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountAcceptedAwardsFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BadgeProfilesEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -58,7 +58,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountBookmarkListFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BookmarkListEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -69,7 +69,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountReportsFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ReportEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -79,7 +79,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { } fun createNotificationFilter() = TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, 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 a97fa5f41..f89e79b77 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -5,6 +5,7 @@ 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.PrivateDmEvent +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter @@ -43,7 +44,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { ) fun createMyChannelsFilter() = TypedFilter( - types = FeedType.values().toSet(), // Metadata comes from any relay + types = COMMON_FEED_TYPES, // Metadata comes from any relay filter = JsonFilter( kinds = listOf(ChannelCreateEvent.kind), ids = account.followingChannels.toList(), @@ -54,7 +55,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { fun createLastChannelInfoFilter(): List { return account.followingChannels.map { TypedFilter( - types = FeedType.values().toSet(), // Metadata comes from any relay + types = COMMON_FEED_TYPES, // Metadata comes from any relay filter = JsonFilter( kinds = listOf(ChannelMetadataEvent.kind), tags = mapOf("e" to listOf(it)), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index 81df891f6..e4233c106 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -4,7 +4,7 @@ import androidx.compose.ui.text.capitalize import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent 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.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -15,7 +15,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { val hashToLoad = hashtagToWatch ?: return null return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( tags = mapOf( "t" to listOf( 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 fda096ec5..29ba72306 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -2,7 +2,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.service.model.* -import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter import nostr.postr.bechToBytes @@ -34,7 +34,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { return listOfNotNull( hexToWatch?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( ids = listOfNotNull(hexToWatch) ) @@ -42,7 +42,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { }, hexToWatch?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), authors = listOfNotNull(hexToWatch) @@ -50,7 +50,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { ) }, TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), search = mySearchString, @@ -58,7 +58,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { ) ), TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind), search = mySearchString, 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 037e03dd7..2d5946418 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service 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.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -40,7 +41,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { // downloads linked events to this event. return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ChannelCreateEvent.kind), ids = interestedEvents.toList() 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 9a6a6568a..61c45d61a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.EOSETime -import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -22,7 +22,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, LongTextNoteEvent.kind, @@ -49,7 +49,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(aTag.kind), tags = mapOf("d" to listOf(aTag.dTag)), @@ -69,7 +69,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return reactionsToWatch.map { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, @@ -108,7 +108,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { // downloads linked events to this event. return listOf( TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, 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 3742ebe5d..ba5457280 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -3,8 +3,8 @@ 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.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.EOSETime -import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -16,7 +16,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { return usersToWatch.filter { it.info?.latestMetadata == null }.map { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), authors = listOf(it.pubkeyHex), @@ -31,7 +31,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { return usersToWatch.map { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ReportEvent.kind), tags = mapOf("p" to listOf(it.pubkeyHex)), 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 7919354c1..ec7ade398 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -1,7 +1,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.ThreadAssembler -import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -18,7 +18,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { .ifEmpty { null } ?: return null return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( ids = eventsToLoad.map { it.substring(0, 8) } ) 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 a4ad8a5fc..19671718a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.* -import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -22,7 +22,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createUserInfoFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), authors = listOf(it.pubkeyHex), @@ -33,7 +33,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createUserPostsFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), authors = listOf(it.pubkeyHex), @@ -44,7 +44,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createUserReceivedZapsFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(LnZapEvent.kind), tags = mapOf("p" to listOf(it.pubkeyHex)) @@ -54,7 +54,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createFollowFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ContactListEvent.kind), authors = listOf(it.pubkeyHex), @@ -65,7 +65,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createFollowersFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ContactListEvent.kind), tags = mapOf("p" to listOf(it.pubkeyHex)) @@ -75,7 +75,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createAcceptedAwardsFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BadgeProfilesEvent.kind), authors = listOf(it.pubkeyHex), @@ -86,7 +86,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createBookmarksFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BookmarkListEvent.kind), authors = listOf(it.pubkeyHex), @@ -97,7 +97,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createReceivedAwardsFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BadgeAwardEvent.kind), tags = mapOf("p" to listOf(it.pubkeyHex)), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt index 83718d310..9f6304336 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey +import nostr.postr.Utils class LnZapPaymentResponseEvent( id: HexKey, @@ -16,9 +17,26 @@ class LnZapPaymentResponseEvent( fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1) - fun response(): Response? = try { + fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? { + return try { + val sharedSecret = Utils.getSharedSecret(privKey, pubKey) + + val retVal = Utils.decrypt(content, sharedSecret) + + if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) { + retVal.substring(16) + } else { + retVal + } + } catch (e: Exception) { + Log.w("PrivateDM", "Error decrypting the message ${e.message}") + null + } + } + + fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try { if (content.isNotEmpty()) { - gson.fromJson(content, Response::class.java) + gson.fromJson(decrypt(privKey, pubKey), Response::class.java) } else { null } 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 c3af984c3..f5631b27e 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 @@ -95,6 +95,10 @@ object Client : RelayPool.Listener { RelayPool.addRelay(relay) relay.requestAndWatch { + allSubscriptions().forEach { + relay.sendFilter(requestId = it) + } + onConnected(relay) GlobalScope.launch(Dispatchers.IO) { 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 ca785f8fc..f93258d32 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 @@ -5,7 +5,6 @@ import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface -import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -17,6 +16,8 @@ enum class FeedType { FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT } +val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) + class Relay( var url: String, var read: Boolean = true, @@ -94,14 +95,11 @@ class Relay( val type = msg[0].asString val channel = msg[1].asString - Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}") + // Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}") when (type) { "EVENT" -> { val event = Event.fromJson(msg[2], Client.lenient) - if (event.kind == LnZapPaymentResponseEvent.kind) { - println("This " + event.toJson()) - } // Log.w("Relay", "Relay onEVENT $url, $channel") listeners.forEach { @@ -117,15 +115,15 @@ class Relay( it.onRelayStateChange(this@Relay, Type.EOSE, channel) } "NOTICE" -> listeners.forEach { - Log.w("Relay", "Relay onNotice $url, $channel") + // Log.w("Relay", "Relay onNotice $url, $channel") it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - Log.w("Relay", "Relay on OK $url, $channel") + // Log.w("Relay", "Relay on OK $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, @@ -205,7 +203,7 @@ class Relay( if (filters.isNotEmpty()) { val request = """["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]""" - // println("FILTERSSENT $url $request") + println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() afterEOSE = false @@ -221,26 +219,6 @@ class Relay( } } - fun sendUnregisteredFilter(request: JsonFilter, subscriptionId: String?) { - if (read) { - if (isConnected()) { - if (isReady) { - val request = """["REQ","${subscriptionId ?: ""}",${request.toJson(url)}]""" - println("FILTERSSENT $url $request") - socket?.send(request) - eventUploadCounterInBytes += request.bytesUsedInMemory() - afterEOSE = false - } - } else { - // waits 60 seconds to reconnect after disconnected. - if (Date().time / 1000 > closingTime + 60) { - // sends all filters after connection is successful. - requestAndWatch() - } - } - } - } - fun sendFilterOnlyIfDisconnected() { if (socket == null) { // waits 60 seconds to reconnect after disconnected. 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 5a2ac2cbd..638856e1d 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 @@ -83,8 +83,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { if (account.hasWalletConnectSetup()) { account.sendZapPaymentRequestFor( bolt11 = it, - onResponse = { - val response = it.response() + onResponse = { response -> if (response is PayInvoiceErrorResponse) { onProgress(0.0f) onError( @@ -93,7 +92,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { ?: "Error parsing error message" ) } else { - // awaits for confirmation from Receiver or timeout. + onProgress(0.99f) } } ) 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 91c6f7b4a..5b6049107 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 @@ -565,8 +565,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode onSuccess = { // pay directly if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) { - val response = it.response() + account.sendZapPaymentRequestFor(it) { response -> if (response is PayInvoiceSuccessResponse) { scope.launch { Toast.makeText( From 8187a7f5a940ea67163492d9588fb3d3667af4d6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 18:54:19 -0400 Subject: [PATCH 056/164] removing debugging log --- .../java/com/vitorpamplona/amethyst/service/relays/Relay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f93258d32..21928f64a 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 @@ -203,7 +203,7 @@ class Relay( if (filters.isNotEmpty()) { val request = """["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]""" - println("FILTERSSENT $url $request") + // println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() afterEOSE = false From 2f2ca97ec17c456d3b3f0d9192873caa92cc80f4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 21:03:01 -0400 Subject: [PATCH 057/164] Updating dependencies. --- app/build.gradle | 8 ++++---- build.gradle | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5e5be2e08..534e0e3f2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -108,13 +108,13 @@ dependencies { implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version" - implementation "net.engawapg.lib:zoomable:1.4.0" + implementation 'net.engawapg.lib:zoomable:1.4.2' // Biometrics implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" // Bitcoin secp256k1 bindings to Android - implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.8.0' + implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0' // Nostr Base Protocol implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') { @@ -127,7 +127,7 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11' // Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both) - implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2' + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' // link preview @@ -176,7 +176,7 @@ dependencies { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' testImplementation 'junit:junit:4.13.2' - testImplementation "io.mockk:mockk:1.13.4" + testImplementation 'io.mockk:mockk:1.13.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" diff --git a/build.gradle b/build.gradle index 347dc9b4d..0c3fda9d7 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { fragment_version = "1.5.6" lifecycle_version = '2.6.1' - compose_ui_version = '1.5.0-alpha02' + compose_ui_version = '1.5.0-alpha03' nav_version = "2.5.3" room_version = "2.4.3" accompanist_version = '0.30.0' From a739b27d1480ed921e7ceeca3e84564d11427324 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 21:04:01 -0400 Subject: [PATCH 058/164] v0.35.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 534e0e3f2..6991b8966 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 123 - versionName "0.34.1" + versionCode 124 + versionName "0.35.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 8f927c29e3a4dd576f8bd7e3639eb309636b8fef Mon Sep 17 00:00:00 2001 From: kiwiidb Date: Tue, 25 Apr 2023 10:15:07 +0200 Subject: [PATCH 059/164] fix bug in nip47 scheme check --- app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt index 854cf4b1c..17c9aa5fc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt @@ -14,7 +14,7 @@ object Nip47 { val url = Uri.parse(uri) - if (url.scheme != "nostrwalletconnect" || url.scheme != "nostr+walletconnect") { + if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") { throw IllegalArgumentException("Not a Wallet Connect QR Code") } From a4ac2eeb89cf549ab8dd3f9cf80f57e4f47259a5 Mon Sep 17 00:00:00 2001 From: Zoltan <34719275+ZsZolee@users.noreply.github.com> Date: Tue, 25 Apr 2023 12:07:01 +0200 Subject: [PATCH 060/164] New Hungarian translation --- app/src/main/res/values-hu/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index dc5073f2a..8dbb1694e 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -289,4 +289,16 @@ Létrehoz és Hozzáad A szavazás létrehozója sajátjára nem szavazhat. #zappoll + + + A kép a bejegyzés óta ugyanaz + A kép megváltozott. Lehet, hogy a szerző nem látta a változást + + Kép Hozzáadása + Videó Hozzáadása + Dokumentum Hozzáadása + Létrehoz és Hozzáad + A tartalom leírása + Naplementekor egy kék csónak egy fehér homokos tengerparton + From a5728daeb0facd834d26dc18330ef55723f059a1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 08:20:51 -0400 Subject: [PATCH 061/164] Adds FileHeader loading to a thread. --- .../amethyst/ui/note/NoteCompose.kt | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) 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 352775a3c..cb53c7175 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 @@ -781,25 +781,29 @@ fun BadgeDisplay(baseNote: Note) { @Composable fun FileHeaderDisplay(note: Note) { val event = (note.event as? FileHeaderEvent) ?: return - val fullUrl = event.url() ?: return - val blurHash = event.blurhash() - val hash = event.hash() - val description = event.content - val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() - val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } - if (isImage || isVideo) { - val content = if (isImage) { - ZoomableImage(fullUrl, description, hash, blurHash) - } else { - ZoomableVideo(fullUrl, description, hash) + var content by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = event.id) { + withContext(Dispatchers.IO) { + val blurHash = event.blurhash() + val hash = event.hash() + val description = event.content + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + content = if (isImage) { + ZoomableImage(fullUrl, description, hash, blurHash) + } else { + ZoomableVideo(fullUrl, description, hash) + } } - ZoomableContentView(content = content, listOf(content)) - } else { - UrlPreview(fullUrl, "$fullUrl ") } + + content?.let { + ZoomableContentView(content = it, listOf(it)) + } ?: UrlPreview(fullUrl, "$fullUrl ") } @Composable From 99861facc9a7b4d26f924499eff21a2a0e145df4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 09:21:12 -0400 Subject: [PATCH 062/164] Improving the performance and UI of Zap notification messages. --- .../vitorpamplona/amethyst/model/Account.kt | 11 ++ .../amethyst/service/model/Event.kt | 2 +- .../service/model/LnZapRequestEvent.kt | 6 +- .../amethyst/ui/note/MultiSetCompose.kt | 151 +++++++++++------- .../ui/screen/loggedIn/AccountViewModel.kt | 5 + 5 files changed, 116 insertions(+), 59 deletions(-) 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 38e78fca5..f86103abb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -691,11 +691,22 @@ class Account( } event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) + } else if (event is LnZapRequestEvent && loggedIn.privKey != null) { + LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!)?.content() } else { event?.content() } } + fun decryptZapContentAuthor(note: Note): Event? { + val event = note.event + return if (event is LnZapRequestEvent && loggedIn.privKey != null) { + LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!) + } else { + null + } + } + fun addDontTranslateFrom(languageCode: String) { dontTranslateFrom = dontTranslateFrom.plus(languageCode) liveLanguages.invalidateData() 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 59f7850e1..9b63fa799 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 @@ -19,7 +19,7 @@ open class Event( @SerializedName("created_at") val createdAt: Long, val kind: Int, val tags: List>, - var content: String, + val content: String, val sig: HexKey ) : EventInterface { override fun id(): HexKey = id 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 32583b549..b672fb570 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 @@ -149,9 +149,9 @@ class LnZapRequestEvent( } fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? { - val anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" } - if (anonTag != null && anonTag.size > 1) { - val encnote = anonTag?.elementAt(1) + val anonTag = zaprequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } + if (anonTag != null) { + val encnote = anonTag[1] if (encnote != null && encnote != "") { try { val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray()) 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 5148c0632..0c848a23f 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -36,10 +37,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent -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.PrivateDmEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -86,9 +84,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun .combinedClickable( onClick = { if (noteEvent is ChannelMessageEvent) { - note.channel()?.let { - navController.navigate("Channel/${it.idHex}") - } + note + .channel() + ?.let { + navController.navigate("Channel/${it.idHex}") + } } else if (noteEvent is PrivateDmEvent) { val replyAuthorBase = (note.event as? PrivateDmEvent) @@ -140,16 +140,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - for (i in multiSetCard.zapEvents) { - var decryptedContent = (i.value.event as LnZapEvent).zapRequest?.let { - LnZapRequestEvent.checkForPrivateZap(it, NostrAccountDataSource.account.loggedIn.privKey!!) - } - if (decryptedContent != null) { - (i.key.event as Event).content = decryptedContent.content - i.key.author = LocalCache.getOrCreateUser(decryptedContent.pubKey) - } - } - AuthorGallery(multiSetCard.zapEvents.keys, navController, account, accountViewModel, "zap") + AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel) } } @@ -222,12 +213,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun } @Composable -fun AuthorGallery( - authorNotes: Collection, +fun AuthorGalleryZaps( + authorNotes: Map, navController: NavController, account: Account, - accountViewModel: AccountViewModel, - kind: String = "nonzap" + accountViewModel: AccountViewModel ) { val accountState by account.userProfile().live().follows.observeAsState() val accountUser = accountState?.user ?: return @@ -235,38 +225,92 @@ fun AuthorGallery( Column(modifier = Modifier.padding(start = 10.dp)) { FlowRow() { authorNotes.forEach { - if (it.event?.content() != "" && kind == "zap") { - Row(Modifier.fillMaxWidth()) { - FastNoteAuthorPicture( - note = it, - navController = navController, - userAccount = accountUser, - size = 35.dp - ) - } - } else { - Row() { - FastNoteAuthorPicture( - note = it, - navController = navController, - userAccount = accountUser, - size = 35.dp - ) - } + AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel) + } + } + } +} + +@Composable +private fun AuthorPictureAndComment( + zapRequest: Note, + zapEvent: Note, + navController: NavController, + accountUser: User, + accountViewModel: AccountViewModel +) { + var content by remember { mutableStateOf>(Pair(zapRequest.author!!, null)) } + + LaunchedEffect(key1 = zapRequest.idHex) { + (zapRequest.event as? LnZapRequestEvent)?.let { + val decryptedContent = accountViewModel.decryptZap(zapRequest) + if (decryptedContent != null) { + val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) + content = Pair(author, decryptedContent.content) + } else { + if (!zapRequest.event?.content().isNullOrBlank()) { + content = Pair(zapRequest.author!!, zapRequest.event?.content()) } - if (it.event?.content() != "" && kind == "zap") { - Row(Modifier.fillMaxWidth()) { - it.event?.let { - TranslatableRichTextViewer( - content = it.content(), - canPreview = true, - tags = null, - backgroundColor = MaterialTheme.colors.background, - accountViewModel = accountViewModel, - navController = navController - ) - } - } + } + } + } + + AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel) +} + +@Composable +private fun AuthorPictureAndComment( + author: User, + comment: String?, + navController: NavController, + accountUser: User, + accountViewModel: AccountViewModel +) { + val modifier = if (!comment.isNullOrBlank()) { + Modifier.fillMaxWidth() + } else { + Modifier + } + + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + FastNoteAuthorPicture( + author = author, + navController = navController, + userAccount = accountUser, + size = 35.dp + ) + + if (!comment.isNullOrBlank()) { + Spacer(modifier = Modifier.width(5.dp)) + TranslatableRichTextViewer( + content = comment, + canPreview = false, + tags = null, + modifier = Modifier.weight(1f), + backgroundColor = MaterialTheme.colors.background, + accountViewModel = accountViewModel, + navController = navController + ) + } + } +} + +@Composable +fun AuthorGallery( + authorNotes: Collection, + navController: NavController, + account: Account, + accountViewModel: AccountViewModel +) { + val accountState by account.userProfile().live().follows.observeAsState() + val accountUser = accountState?.user ?: return + + Column(modifier = Modifier.padding(start = 10.dp)) { + FlowRow() { + authorNotes.forEach { + val author = it.author + if (author != null) { + AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel) } } } @@ -275,15 +319,12 @@ fun AuthorGallery( @Composable fun FastNoteAuthorPicture( - note: Note, + author: User, navController: NavController, userAccount: User, size: Dp, pictureModifier: Modifier = Modifier ) { - // can't be null if here - val author = note.author ?: return - val userState by author.live().metadata.observeAsState() val user = userState?.user ?: return 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 638856e1d..7c3f24079 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 @@ -14,6 +14,7 @@ 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.Event import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse import com.vitorpamplona.amethyst.service.model.ReportEvent @@ -164,6 +165,10 @@ class AccountViewModel(private val account: Account) : ViewModel() { return account.decryptContent(note) } + fun decryptZap(note: Note): Event? { + return account.decryptZapContentAuthor(note) + } + fun hide(user: User) { account.hideUser(user.pubkeyHex) } From f7182d6393a8f0a1d0091cd3ddd5f0d7c0d04ce1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 13:37:39 -0400 Subject: [PATCH 063/164] Preview for quote noting. --- .../com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a7f07605f..a07701358 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 @@ -82,7 +82,8 @@ open class NewPostViewModel : ViewModel() { } quote?.let { - message = TextFieldValue(message.text + "\n\n@${it.toNEvent()}") + message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}") + urlPreview = findUrlInMessage() } canAddInvoice = account.userProfile().info?.lnAddress() != null From 91f3f036ae036212c624f727f73c547867f09352 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 16:35:42 -0400 Subject: [PATCH 064/164] Making poll votes work with Private Zaps. --- .../vitorpamplona/amethyst/PrivateZapTests.kt | 139 ++++++++++++++++++ .../vitorpamplona/amethyst/model/Account.kt | 41 +++++- .../com/vitorpamplona/amethyst/model/Note.kt | 6 +- .../service/model/LnZapRequestEvent.kt | 21 +-- .../amethyst/ui/note/PollNote.kt | 42 ++++-- .../amethyst/ui/note/PollNoteViewModel.kt | 21 ++- .../amethyst/ui/note/ReactionsRow.kt | 12 +- .../ui/screen/loggedIn/AccountViewModel.kt | 4 + 8 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt new file mode 100644 index 000000000..c131a8af3 --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt @@ -0,0 +1,139 @@ +package com.vitorpamplona.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.model.toHexKey +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.LnZapRequestEvent.Companion.createEncryptionPrivateKey +import fr.acinq.secp256k1.Hex +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.fail +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PrivateZapTests { + + @Test + fun testPollZap() { + val poll = Event.Companion.fromJson( + """{ + "content": "New poll \n\n #zappoll", + "created_at": 1682440713, + "id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288", + "kind": 6969, + "pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + "sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279", + "tags": [ + [ + "poll_option", + "0", + "Test 1" + ], + [ + "poll_option", + "1", + "Test 2" + ], + [ + "value_maximum", + "null" + ], + [ + "value_minimum", + "null" + ], + [ + "consensus_threshold", + "null" + ], + [ + "closed_at", + "null" + ] + ], + "seenOn": [ + "wss://relay.damus.io/" + ] +} +""", + true + ) + + val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598") + + val privateZapRequest = LnZapRequestEvent.create( + poll, + setOf("wss://relay.damus.io/"), + loggedIn, + 0, + "", + LnZapEvent.ZapType.PRIVATE + ) + + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) + val decodedPrivateZap = + LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK) + + println(decodedPrivateZap?.toJson()) + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } + } + + @Test + fun testKind1PrivateZap() { + val textNote = Event.Companion.fromJson( + """{ + "content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + "created_at": 1682369982, + "id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1", + "kind": 1, + "pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + "sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ], + "seenOn": [ + "wss://relay.damus.io/" + ] +} +""", + true + ) + + val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598") + + val privateZapRequest = LnZapRequestEvent.create( + textNote, + setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"), + loggedIn, + null, + "test", + LnZapEvent.ZapType.PRIVATE + ) + + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) + val decodedPrivateZap = + LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK) + + println(decodedPrivateZap?.toJson()) + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } + } +} 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 f86103abb..95b037e9a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -692,7 +692,7 @@ class Account( event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) } else if (event is LnZapRequestEvent && loggedIn.privKey != null) { - LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!)?.content() + decryptZapContentAuthor(note)?.content() } else { event?.content() } @@ -700,8 +700,43 @@ class Account( fun decryptZapContentAuthor(note: Note): Event? { val event = note.event - return if (event is LnZapRequestEvent && loggedIn.privKey != null) { - LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!) + val loggedInPrivateKey = loggedIn.privKey + + return if (event is LnZapRequestEvent && loggedInPrivateKey != null && event.isPrivateZap()) { + val recipientPK = event.zappedAuthor().firstOrNull() + val recipientPost = event.zappedPost().firstOrNull() + + if (recipientPK == userProfile().pubkeyHex) { + // if the receiver is logged in, these are the params. + val privateKeyToUse = loggedInPrivateKey + val pubkeyToUse = event.pubKey + + LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse) + } else { + // if the sender is logged in, these are the params + val altPubkeyToUse = recipientPK + val altPrivateKeyToUse = if (recipientPost != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + loggedInPrivateKey.toHexKey(), + recipientPost, + event.createdAt + ) + } else if (recipientPK != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + loggedInPrivateKey.toHexKey(), + recipientPK, + event.createdAt + ) + } else { + null + } + + if (altPrivateKeyToUse != null && altPubkeyToUse != null) { + LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse) + } else { + null + } + } } else { null } 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 0b145008f..4c4959398 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -200,9 +200,11 @@ open class Note(val idHex: String) { } } - fun isZappedBy(user: User): Boolean { + fun isZappedBy(user: User, account: Account): Boolean { // Zaps who the requester was the user - return zaps.any { it.key.author === user } + return zaps.any { + it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex + } } fun isReactedBy(user: User): Boolean { 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 b672fb570..0fc12d565 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 @@ -24,6 +24,8 @@ class LnZapRequestEvent( fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } + companion object { const val kind = 9734 @@ -135,26 +137,27 @@ class LnZapRequestEvent( if (parts.size != 2) { throw IllegalArgumentException("Invalid message format") } - val iv = parts[1].run { Bech32.decode(this) } - val encryptedMsg = parts.first().run { Bech32.decode(this) } + val iv = parts[1].run { Bech32.decode(this).second } + val encryptedMsg = parts.first().run { Bech32.decode(this).second } + val encryptedBytes = Bech32.five2eight(encryptedMsg, 0) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv.second, 0))) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0))) try { - val decryptedMsgBytes = cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0)) + val decryptedMsgBytes = cipher.doFinal(encryptedBytes) return String(decryptedMsgBytes) } catch (ex: BadPaddingException) { - throw IllegalArgumentException("Bad padding") + throw IllegalArgumentException("Bad padding: ${ex.message}") } } - fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? { - val anonTag = zaprequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } + fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? { + val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } if (anonTag != null) { val encnote = anonTag[1] - if (encnote != null && encnote != "") { + if (encnote.isNotBlank()) { try { - val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray()) + val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray()) val decryptedEvent = fromJson(note) if (decryptedEvent.kind == 9733) { return decryptedEvent diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 28ffb025f..ee2f86dc2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -31,12 +31,12 @@ import androidx.compose.ui.window.Popup import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.* import kotlin.math.roundToInt @@ -51,7 +51,11 @@ fun PollNote( val zapsState by baseNote.live().zaps.observeAsState() val zappedNote = zapsState?.note ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + val pollViewModel = PollNoteViewModel() + pollViewModel.account = account pollViewModel.load(zappedNote) pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> @@ -69,7 +73,7 @@ fun PollNote( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 3.dp) ) { - if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) { + if (pollViewModel.canZap()) { ZapVote( baseNote, accountViewModel, @@ -288,7 +292,17 @@ fun ZapVote( clickablePrepend() - if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) { + var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zappedNote) { + withContext(Dispatchers.IO) { + if (!optionWasZappedByLoggedInUser) { + optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile()) + } + } + } + + if (optionWasZappedByLoggedInUser) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, @@ -315,8 +329,18 @@ fun ZapVote( } } + var wasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zappedNote) { + withContext(Dispatchers.IO) { + if (!wasZappedByLoggedInUser) { + wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true + } + } + } + // only show tallies after a user has zapped note - if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) { + if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) { Text( showAmount(pollViewModel.zappedPollOptionAmount(pollOption)), fontSize = 14.sp, @@ -378,7 +402,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -404,7 +428,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -496,8 +520,7 @@ fun ZapVoteAmountChoicePopup( "", context, onError, - onProgress, - LnZapEvent.ZapType.PUBLIC + onProgress ) onDismiss() } @@ -522,8 +545,7 @@ fun ZapVoteAmountChoicePopup( "", context, onError, - onProgress, - LnZapEvent.ZapType.PUBLIC + onProgress ) onDismiss() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index b24d78fbd..3ad8ac90f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -33,6 +33,13 @@ class PollNoteViewModel { totalZapped = totalZapped() } + fun canZap(): Boolean { + val account = account ?: return false + val user = account.userProfile() ?: return false + val note = pollNote ?: return false + return user != note.author && !note.isZappedBy(user, account) + } + fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum fun isPollClosed(): Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate @@ -87,14 +94,12 @@ class PollNoteViewModel { } fun isPollOptionZappedBy(option: Int, user: User): Boolean { - if (pollNote?.zaps?.any { it.key.author === user } == true) { - pollNote!!.zaps - .any { - val event = it.value?.event as? LnZapEvent - event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex - } - } - return false + return pollNote!!.zaps + .any { + val zapEvent = it.value?.event as? LnZapEvent + val privateZapAuthor = account?.decryptZapContentAuthor(it.key) + zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex) + } } fun zappedPollOptionAmount(option: Int): BigDecimal { 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 f334df087..c019d3acc 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 @@ -314,6 +314,16 @@ fun ZapReaction( var zappingProgress by remember { mutableStateOf(0f) } + var wasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zappedNote) { + withContext(Dispatchers.IO) { + if (!wasZappedByLoggedInUser) { + wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true + } + } + } + Row( verticalAlignment = CenterVertically, modifier = Modifier @@ -412,7 +422,7 @@ fun ZapReaction( ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote) } - if (zappedNote?.isZappedBy(account.userProfile()) == true) { + if (wasZappedByLoggedInUser) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, 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 7c3f24079..8cce0da97 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 @@ -55,6 +55,10 @@ class AccountViewModel(private val account: Account) : ViewModel() { account.delete(account.boostsTo(note)) } + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) { + zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType) + } + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() From b9f58b37010c2a31b24380d3b9261898dda3ca14 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 17:02:44 -0400 Subject: [PATCH 065/164] Correctly updating the screen after zaps have been accounted for --- .../amethyst/ui/note/PollNote.kt | 216 +++++++++++------- .../amethyst/ui/note/ReactionsRow.kt | 2 +- 2 files changed, 130 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index ee2f86dc2..2d7f76f8c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.math.BigDecimal import java.util.* import kotlin.math.roundToInt @@ -59,101 +60,142 @@ fun PollNote( pollViewModel.load(zappedNote) pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> - val optionTally = pollViewModel.optionVoteTally(poll_op.key) - val color = if ( - pollViewModel.consensusThreshold != null && - optionTally >= pollViewModel.consensusThreshold!! - ) { - Color.Green.copy(alpha = 0.32f) - } else { - MaterialTheme.colors.primary.copy(alpha = 0.32f) - } + OptionNote( + poll_op.key, + poll_op.value, + pollViewModel, + baseNote, + accountViewModel, + canPreview, + backgroundColor, + navController + ) + } +} - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 3.dp) - ) { - if (pollViewModel.canZap()) { - ZapVote( - baseNote, - accountViewModel, - pollViewModel, - poll_op.key, - nonClickablePrepend = { - Box( - Modifier.fillMaxWidth(0.75f).clip(shape = RoundedCornerShape(15.dp)) - .border( - 2.dp, - color, - RoundedCornerShape(15.dp) - ) - ) { - LinearProgressIndicator( - modifier = Modifier.matchParentSize(), - color = color, - progress = optionTally.toFloat() +@Composable +private fun OptionNote( + optionNumber: Int, + optionText: String, + pollViewModel: PollNoteViewModel, + baseNote: Note, + accountViewModel: AccountViewModel, + canPreview: Boolean, + backgroundColor: Color, + navController: NavController +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 3.dp) + ) { + if (!pollViewModel.canZap()) { + val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) + var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) } + + LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) { + val myTally = pollViewModel.optionVoteTally(optionNumber) + val color = if ( + pollViewModel.consensusThreshold != null && + myTally >= pollViewModel.consensusThreshold!! + ) { + Color.Green.copy(alpha = 0.32f) + } else { + defaultColor + } + + if (myTally > optionTally.first || color != optionTally.second) { + optionTally = Pair(myTally, color) + } + } + + ZapVote( + baseNote, + accountViewModel, + pollViewModel, + optionNumber, + nonClickablePrepend = { + Box( + Modifier + .fillMaxWidth(0.75f) + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 2.dp, + optionTally.second, + RoundedCornerShape(15.dp) ) + ) { + LinearProgressIndicator( + modifier = Modifier.matchParentSize(), + color = optionTally.second, + progress = optionTally.first.toFloat() + ) - Row( - verticalAlignment = Alignment.CenterVertically + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier + .padding(horizontal = 10.dp) + .width(40.dp) ) { - Column( - horizontalAlignment = Alignment.End, - modifier = Modifier.padding(horizontal = 10.dp).width(40.dp) - ) { - Text( - text = "${(optionTally.toFloat() * 100).roundToInt()}%", - fontWeight = FontWeight.Bold - ) - } + Text( + text = "${(optionTally.first.toFloat() * 100).roundToInt()}%", + fontWeight = FontWeight.Bold + ) + } - Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) { - TranslatableRichTextViewer( - poll_op.value, - canPreview, - Modifier, - pollViewModel.pollEvent?.tags(), - backgroundColor, - accountViewModel, - navController - ) - } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(15.dp) + ) { + TranslatableRichTextViewer( + optionText, + canPreview, + Modifier, + pollViewModel.pollEvent?.tags(), + backgroundColor, + accountViewModel, + navController + ) } } - }, - clickablePrepend = { } - ) - } else { - ZapVote( - baseNote, - accountViewModel, - pollViewModel, - poll_op.key, - nonClickablePrepend = {}, - clickablePrepend = { - Box( - Modifier.fillMaxWidth(0.75f) - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 2.dp, - MaterialTheme.colors.primary, - RoundedCornerShape(15.dp) - ) - ) { - TranslatableRichTextViewer( - poll_op.value, - canPreview, - Modifier.padding(15.dp), - pollViewModel.pollEvent?.tags(), - backgroundColor, - accountViewModel, - navController + }, + clickablePrepend = { + } + ) + } else { + ZapVote( + baseNote, + accountViewModel, + pollViewModel, + optionNumber, + nonClickablePrepend = {}, + clickablePrepend = { + Box( + Modifier + .fillMaxWidth(0.75f) + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 2.dp, + MaterialTheme.colors.primary, + RoundedCornerShape(15.dp) ) - } + ) { + TranslatableRichTextViewer( + optionText, + canPreview, + Modifier.padding(15.dp), + pollViewModel.pollEvent?.tags(), + backgroundColor, + accountViewModel, + navController + ) } - ) - } + } + ) } } } @@ -294,7 +336,7 @@ fun ZapVote( var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) } - LaunchedEffect(key1 = zappedNote) { + LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!optionWasZappedByLoggedInUser) { optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile()) @@ -331,7 +373,7 @@ fun ZapVote( var wasZappedByLoggedInUser by remember { mutableStateOf(false) } - LaunchedEffect(key1 = zappedNote) { + LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true 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 c019d3acc..4b704985d 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 @@ -316,7 +316,7 @@ fun ZapReaction( var wasZappedByLoggedInUser by remember { mutableStateOf(false) } - LaunchedEffect(key1 = zappedNote) { + LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true From d070712f2b9e5926983154ac5a51aac9b3e0ad45 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 17:12:48 -0400 Subject: [PATCH 066/164] Activates gif preview in Notifications --- .../java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0c848a23f..d2e20ad86 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 @@ -284,7 +284,7 @@ private fun AuthorPictureAndComment( Spacer(modifier = Modifier.width(5.dp)) TranslatableRichTextViewer( content = comment, - canPreview = false, + canPreview = true, tags = null, modifier = Modifier.weight(1f), backgroundColor = MaterialTheme.colors.background, From add6b0644631b9c740d39ff22cedddd63e4be269 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 17:13:07 -0400 Subject: [PATCH 067/164] Marks all Zaps to be private by default. --- app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 95b037e9a..c81f0c970 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -44,7 +44,7 @@ class Account( var languagePreferences: Map = mapOf(), var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), - var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, From e958515dad9d1c3e995f2602582ac0b6a374219b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 18:06:00 -0400 Subject: [PATCH 068/164] Improves configuration of Zap types (double click and click and hold) --- .../amethyst/ui/components/TextSpinner.kt | 30 +- .../amethyst/ui/note/MultiSetCompose.kt | 6 +- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 559 ++++++++++-------- .../amethyst/ui/note/ZapCustomDialog.kt | 38 +- app/src/main/res/values/strings.xml | 15 + 5 files changed, 371 insertions(+), 277 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index acd75cd5b..7a4b3ca16 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -5,8 +5,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -29,7 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @Composable -fun TextSpinner(label: String, placeholder: String, options: List, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) { +fun TextSpinner(label: String, placeholder: String, options: List, explainers: List? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) { val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } var optionsShowing by remember { mutableStateOf(false) } @@ -62,7 +65,7 @@ fun TextSpinner(label: String, placeholder: String, options: List, onSel if (optionsShowing) { options.isNotEmpty().also { - SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) { + SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) { currentText = options[it] optionsShowing = false onSelect(it) @@ -72,7 +75,7 @@ fun TextSpinner(label: String, placeholder: String, options: List, onSel } @Composable -fun SpinnerSelectionDialog(options: List, onDismiss: () -> Unit, onSelect: (Int) -> Unit) { +fun SpinnerSelectionDialog(options: List, explainers: List?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) { Dialog(onDismissRequest = onDismiss) { Surface( border = BorderStroke(0.25.dp, Color.LightGray), @@ -81,7 +84,6 @@ fun SpinnerSelectionDialog(options: List, onDismiss: () -> Unit, onSelec LazyColumn() { itemsIndexed(options) { index, item -> Row( - horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() .padding(16.dp, 16.dp) @@ -89,7 +91,25 @@ fun SpinnerSelectionDialog(options: List, onDismiss: () -> Unit, onSelec onSelect(index) } ) { - Text(text = item, color = MaterialTheme.colors.onSurface) + Column() { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = item, color = MaterialTheme.colors.onSurface) + } + explainers?.getOrNull(index)?.let { + Spacer(modifier = Modifier.height(5.dp)) + Row( + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = it, color = Color.Gray) + } + } + } } if (index < options.lastIndex) { Divider(color = Color.LightGray, thickness = 0.25.dp) 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 d2e20ad86..95433ce74 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 @@ -239,7 +239,9 @@ private fun AuthorPictureAndComment( accountUser: User, accountViewModel: AccountViewModel ) { - var content by remember { mutableStateOf>(Pair(zapRequest.author!!, null)) } + val author = zapRequest.author ?: return + + var content by remember { mutableStateOf>(Pair(author, null)) } LaunchedEffect(key1 = zapRequest.idHex) { (zapRequest.event as? LnZapRequestEvent)?.let { @@ -249,7 +251,7 @@ private fun AuthorPictureAndComment( content = Pair(author, decryptedContent.content) } else { if (!zapRequest.event?.content().isNullOrBlank()) { - content = Pair(zapRequest.author!!, zapRequest.event?.content()) + content = Pair(author, zapRequest.event?.content()) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 7b0899c6b..f2c0ac0da 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -69,9 +70,11 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -85,6 +88,7 @@ class UpdateZapAmountViewModel : ViewModel() { var walletConnectRelay by mutableStateOf(TextFieldValue("")) var walletConnectPubkey by mutableStateOf(TextFieldValue("")) var walletConnectSecret by mutableStateOf(TextFieldValue("")) + var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) fun load(account: Account) { this.account = account @@ -92,6 +96,7 @@ class UpdateZapAmountViewModel : ViewModel() { this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.selectedZapType = account.defaultZapType } fun toListOfAmounts(commaSeparatedAmounts: String): List { @@ -113,6 +118,7 @@ class UpdateZapAmountViewModel : ViewModel() { fun sendPost() { account?.changeZapAmounts(amountSet) + account?.changeDefaultZapType(selectedZapType) if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { val pubkeyHex = try { @@ -160,7 +166,8 @@ class UpdateZapAmountViewModel : ViewModel() { fun hasChanged(): Boolean { return ( - amountSet != account?.zapAmountChoices || + selectedZapType != account?.defaultZapType || + amountSet != account?.zapAmountChoices || walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") || walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "") @@ -188,6 +195,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin val postViewModel: UpdateZapAmountViewModel = viewModel() val uri = LocalUriHandler.current + val zapTypes = listOf( + Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)), + Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)), + Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)), + Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer)) + ) + + val zapOptions = zapTypes.map { it.second } + val zapOptionExplainers = zapTypes.map { it.third } + LaunchedEffect(account) { postViewModel.load(account) if (nip47uri != null) { @@ -205,12 +222,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin Dialog( onDismissRequest = { onClose() }, properties = DialogProperties( + usePlatformDefaultWidth = false, dismissOnClickOutside = false, - usePlatformDefaultWidth = false + decorFitsSystemWindows = false ) ) { - Surface() { - Column(modifier = Modifier.padding(10.dp)) { + Surface( + modifier = Modifier + .fillMaxWidth() + ) { + Column(modifier = Modifier.padding(10.dp).imePadding()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -232,261 +253,293 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin Spacer(modifier = Modifier.height(10.dp)) - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) + Row( + modifier = Modifier + .fillMaxWidth() ) { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - postViewModel.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) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + 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 ) - } ✖", - color = Color.White, - textAlign = TextAlign.Center + } + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + 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) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextSpinner( + label = stringResource(id = R.string.zap_type_explainer), + placeholder = zapTypes.filter { it.first == account.defaultZapType } + .first().second, + options = zapOptions, + explainers = zapOptionExplainers, + onSelect = { + postViewModel.selectedZapType = zapTypes[it].first + }, + modifier = Modifier + .weight(1f) + .padding(end = 5.dp) + ) + } + + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = 0.25.dp + ) + + var qrScanning by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(id = R.string.wallet_connect_service), + Modifier.weight(1f) + ) + + IconButton(onClick = { + runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") } + }) { + Icon( + painter = painterResource(R.drawable.alby), + null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + } + + IconButton(onClick = { + qrScanning = true + }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(id = R.string.wallet_connect_service_explainer), + Modifier.weight(1f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontSize = 14.sp + ) + } + + if (qrScanning) { + SimpleQrCodeScanner { + qrScanning = false + if (!it.isNullOrEmpty()) { + try { + postViewModel.updateNIP47(it) + } catch (e: IllegalArgumentException) { + scope.launch { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT) + .show() + } + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) }, + value = postViewModel.walletConnectPubkey, + onValueChange = { + postViewModel.walletConnectPubkey = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None + ), + placeholder = { + Text( + text = "npub, hex", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectRelay, + onValueChange = { postViewModel.walletConnectRelay = it }, + placeholder = { + Text( + text = "wss://relay.server.com", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1 + ) + }, + singleLine = true + ) + } + + var showPassword by remember { + mutableStateOf(false) + } + + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + showPassword = true + } + } + + val authTitle = + stringResource(id = R.string.wallet_connect_service_show_secret) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectSecret, + onValueChange = { postViewModel.walletConnectSecret = it }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go + ), + placeholder = { + Text( + text = stringResource(R.string.wallet_connect_service_secret_placeholder), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + trailingIcon = { + IconButton(onClick = { + if (!showPassword) { + authenticate( + authTitle, + context, + scope, + keyguardLauncher + ) { + showPassword = true + } + } else { + showPassword = false + } + }) { + 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 + ) + } ) } - } - } - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - 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) - } - } - - Divider( - modifier = Modifier.padding(vertical = 10.dp), - thickness = 0.25.dp - ) - - var qrScanning by remember { mutableStateOf(false) } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(id = R.string.wallet_connect_service), - Modifier.weight(1f) - ) - - IconButton(onClick = { - runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") } - }) { - Icon( - painter = painterResource(R.drawable.alby), - null, - modifier = Modifier.size(24.dp), - tint = Color.Unspecified + }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation() ) } - - IconButton(onClick = { - qrScanning = true - }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colors.primary - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(id = R.string.wallet_connect_service_explainer), - Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - fontSize = 14.sp - ) - } - - if (qrScanning) { - SimpleQrCodeScanner { - qrScanning = false - if (!it.isNullOrEmpty()) { - try { - postViewModel.updateNIP47(it) - } catch (e: IllegalArgumentException) { - scope.launch { - Toast.makeText(context, e.message, Toast.LENGTH_SHORT) - .show() - } - } - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) }, - value = postViewModel.walletConnectPubkey, - onValueChange = { - postViewModel.walletConnectPubkey = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None - ), - placeholder = { - Text( - text = "npub, hex", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - singleLine = true, - modifier = Modifier.weight(1f) - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectRelay, - onValueChange = { postViewModel.walletConnectRelay = it }, - placeholder = { - Text( - text = "wss://relay.server.com", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - maxLines = 1 - ) - }, - singleLine = true - ) - } - - var showPassword by remember { - mutableStateOf(false) - } - - val scope = rememberCoroutineScope() - val context = LocalContext.current - - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - showPassword = true - } - } - - val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectSecret, - onValueChange = { postViewModel.walletConnectSecret = it }, - keyboardOptions = KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go - ), - placeholder = { - Text( - text = stringResource(R.string.wallet_connect_service_secret_placeholder), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - trailingIcon = { - IconButton(onClick = { - if (!showPassword) { - authenticate(authTitle, context, scope, keyguardLauncher) { - showPassword = true - } - } else { - showPassword = false - } - }) { - 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 - ) - } - ) - } - }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation() - ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 7ea074c96..5518a3aa6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -60,6 +60,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc val context = LocalContext.current val scope = rememberCoroutineScope() val postViewModel: ZapOptionstViewModel = viewModel() + LaunchedEffect(account) { postViewModel.load(account) } @@ -67,13 +68,14 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc var zappingProgress by remember { mutableStateOf(0f) } val zapTypes = listOf( - Pair(LnZapEvent.ZapType.PUBLIC, "Public"), - Pair(LnZapEvent.ZapType.PRIVATE, "Private"), - Pair(LnZapEvent.ZapType.ANONYMOUS, "Anonymous"), - Pair(LnZapEvent.ZapType.NONZAP, "Non-Zap") + Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)), + Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)), + Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)), + Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer)) ) val zapOptions = zapTypes.map { it.second } + val zapOptionExplainers = zapTypes.map { it.third } var selectedZapType by remember { mutableStateOf(account.defaultZapType) } Dialog( @@ -150,11 +152,23 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc }, singleLine = true, modifier = Modifier - .padding(end = 10.dp) + .padding(end = 5.dp) .weight(1f) ) + + TextSpinner( + label = stringResource(id = R.string.zap_type), + placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second, + options = zapOptions, + explainers = zapOptionExplainers, + onSelect = { + selectedZapType = zapTypes[it].first + }, + modifier = Modifier + .weight(1f) + .padding(end = 5.dp) + ) } - Spacer(modifier = Modifier.height(5.dp)) Row( modifier = Modifier @@ -189,20 +203,10 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc }, singleLine = true, modifier = Modifier - .padding(end = 10.dp) + .padding(end = 5.dp) .weight(1f) ) } - TextSpinner( - label = "Zap Type", - placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second, - options = zapOptions, - onSelect = { - selectedZapType = zapTypes[it].first - account.changeDefaultZapType(selectedZapType) - }, - modifier = Modifier.fillMaxWidth() - ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c0125fb8..bbab09bf3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -307,4 +307,19 @@ Description of the contents A blue boat in a white sandy beach at sunset + Zap Type + Zap Type for all options + + Public + Everybody can see the transaction and message + + Private + Sender and Receiver can see each other and read the message + + Anonymous + Receiver and the public does not know who sent the payment + + Non-Zap + No trace in Nostr, only in Lightning + From f38f5737b0a0ef027ef7c001bfeafdb2d72cc103 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 18:11:08 -0400 Subject: [PATCH 069/164] Opens image dialog in the page of the current selection. --- .../amethyst/ui/components/ZoomableContentView.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 8f8c15cfa..cc840bf07 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -248,6 +248,13 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List -1) { + pagerState.scrollToPage(page) + } + } + Row( modifier = Modifier .padding(10.dp) From a22ec35a26b86dc981967b519820317b0bf24441 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 18:16:51 -0400 Subject: [PATCH 070/164] v0.35.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6991b8966..f39c4009f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 124 - versionName "0.35.0" + versionCode 125 + versionName "0.35.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 90955515d0d3d7cf86d3fadbf33e55143c5fb145 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 18:26:18 -0400 Subject: [PATCH 071/164] Updating readme with the new features. --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7d2eb1ce5..a8eb5c59b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Relay Sets (home, dms, public chats, global) - [x] User/Note Tagging (NIP-08, NIP-10) - [x] Lightning Tips -- [x] Zaps (NIP-57) +- [x] Zaps (private, public, anon, non-zap) (NIP-57) - [x] URI Support (NIP-21) - [x] Event Deletion (NIP-09: like, boost, text notes and reports) - [x] Identity Verification (NIP-05) @@ -41,7 +41,17 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Online Relay Search (NIP-50) - [x] Internationalization - [x] Badges (NIP-58) -- [x] Hashtags +- [x] Hashtag Following and Custom Hashtags +- [x] Polls (NIP-69) +- [x] Verifiable uploads (NIP-94) +- [x] Login with QR +- [x] Wallet Connect API (NIP-47) +- [x] Accessible uploads +- [x] Bounty support (nostrbounties.com) +- [x] De-googled F-Droid flavor +- [x] External Identity Support (NIP-39) +- [x] Multiple Accounts +- [x] Markdown Support - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) @@ -59,7 +69,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [ ] Delegated Event Signing (NIP-26) - [ ] Account Creation / Backup Guidance (NIP-06) - [ ] Message Sent feedback (NIP-20) -- [ ] Polls (NIP-69) + # Development Overview From 14bc1fb7d46bb086047dc906639b596a204e3230 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 21:18:33 -0400 Subject: [PATCH 072/164] Support for NIP-42 --- README.md | 2 +- .../vitorpamplona/amethyst/model/Account.kt | 6 ++++ .../service/NostrAccountDataSource.kt | 17 ++++++++++ .../amethyst/service/NostrDataSource.kt | 5 +++ .../amethyst/service/model/RelayAuthEvent.kt | 34 +++++++++++++++++++ .../amethyst/service/relays/Client.kt | 10 ++++++ .../amethyst/service/relays/Relay.kt | 6 ++++ .../amethyst/service/relays/RelayPool.kt | 6 ++++ 8 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt diff --git a/README.md b/README.md index a8eb5c59b..c6d9da94b 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,12 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] External Identity Support (NIP-39) - [x] Multiple Accounts - [x] Markdown Support +- [x] Relay Authentication (NIP-42) - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) - [ ] Sensitive Content (NIP-36) - [ ] Relay Pages (NIP-11) -- [ ] Relay Authentication (NIP-42) - [ ] Generic Tags (NIP-12) - [ ] Proof of Work in the Phone (NIP-13, NIP-20) - [ ] Events with a Subject (NIP-14) 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 c81f0c970..e09112dae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -569,6 +569,12 @@ class Account( LocalCache.consume(event) } + fun createAuthEvent(relay: Relay, challenge: String): RelayAuthEvent? { + if (!isWriteable()) return null + + return RelayAuthEvent.create(relay.url, challenge, loggedIn.privKey!!) + } + fun removePublicBookmark(note: Note) { if (!isWriteable()) return 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 a3546f003..3025a742f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -14,8 +14,10 @@ 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.COMMON_FEED_TYPES +import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.TypedFilter object NostrAccountDataSource : NostrDataSource("AccountData") { @@ -113,4 +115,19 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { createAccountBookmarkListFilter() ).ifEmpty { null } } + + override fun auth(relay: Relay, challenge: String) { + super.auth(relay, challenge) + + if (this::account.isInitialized) { + val event = account.createAuthEvent(relay, challenge) + + if (event != null) { + Client.send( + event, + relay.url + ) + } + } + } } 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 206a6538a..bdabd984f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -126,6 +126,10 @@ abstract class NostrDataSource(val debugName: String) { override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) { } + + override fun onAuth(relay: Relay, challenge: String) { + auth(relay, challenge) + } } init { @@ -221,4 +225,5 @@ abstract class NostrDataSource(val debugName: String) { } abstract fun updateChannelFilters() + open fun auth(relay: Relay, challenge: String) = Unit } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt new file mode 100644 index 000000000..6a7e04228 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt @@ -0,0 +1,34 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class RelayAuthEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1) + fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1) + + companion object { + const val kind = 22242 + + fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent { + val content = "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = listOf( + listOf("relay", relay), + listOf("challenge", challenge) + ) + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} 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 f5631b27e..256e1b804 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 @@ -160,6 +160,14 @@ object Client : RelayPool.Listener { } } + override fun onAuth(relay: Relay, challenge: String) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onAuth(relay, challenge) } + } + } + fun subscribe(listener: Listener) { listeners = listeners.plus(listener) } @@ -196,5 +204,7 @@ object Client : RelayPool.Listener { * When an relay saves or rejects a new event. */ open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit + + open fun onAuth(relay: Relay, challenge: String) = Unit } } 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 21928f64a..68137cd57 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 @@ -122,6 +122,10 @@ class Relay( // Log.w("Relay", "Relay on OK $url, $channel") it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString) } + "AUTH" -> listeners.forEach { + // Log.w("Relay", "Relay AUTH $url, $channel") + it.onAuth(this@Relay, msg[1].asString) + } else -> listeners.forEach { // Log.w("Relay", "Relay something else $url, $channel") it.onError( @@ -272,6 +276,8 @@ class Relay( fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) + fun onAuth(relay: Relay, challenge: 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 e1d5bb215..702bdc1b2 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 @@ -93,6 +93,8 @@ object RelayPool : Relay.Listener { fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) + + fun onAuth(relay: Relay, challenge: String) } override fun onEvent(relay: Relay, subscriptionId: String, event: Event) { @@ -113,6 +115,10 @@ object RelayPool : Relay.Listener { listeners.forEach { it.onSendResponse(eventId, success, message, relay) } } + override fun onAuth(relay: Relay, challenge: String) { + listeners.forEach { it.onAuth(relay, challenge) } + } + // Observers line up here. val live: RelayPoolLiveData = RelayPoolLiveData(this) From 24d722cdb72a7b52986e488b66a5540682be18f6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 21:19:32 -0400 Subject: [PATCH 073/164] v0.36.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f39c4009f..680e8c2af 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 125 - versionName "0.35.1" + versionCode 126 + versionName "0.36.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 3175d4048274edb8db473c5f464206b1f367a59a Mon Sep 17 00:00:00 2001 From: Zoltan <34719275+ZsZolee@users.noreply.github.com> Date: Wed, 26 Apr 2023 11:08:13 +0200 Subject: [PATCH 074/164] Zap Type Hungarian translation --- app/src/main/res/values-hu/strings.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 8dbb1694e..ab2e4a80a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -301,4 +301,18 @@ A tartalom leírása Naplementekor egy kék csónak egy fehér homokos tengerparton + Zap Típusa + Zap típus minden opcióhoz + + Publikus + Mindenki láthatja a tranzakciót és az üzenetet + + Privát + A küldő és a fogadó láthatja egymást és az üzenetet is elolvashatja + + Névtelen + A címzett és a nyilvánosság nem tudja ki küldte a fizetést + + Nem Zap + Nostr-ban nyoma sincs, csak a Lightning-ben From c6a0b0950ac361a4c9a417d7c7435eba8fafa1dd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 13:51:22 -0400 Subject: [PATCH 075/164] Fixes AUTH NIP42 bug. Amethyst was sending the event as a regular EVENT and not an AUTH command --- .../amethyst/service/relays/Relay.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 68137cd57..653a1fdf6 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 @@ -5,6 +5,7 @@ import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface +import com.vitorpamplona.amethyst.service.model.RelayAuthEvent import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -119,11 +120,11 @@ class Relay( it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - // Log.w("Relay", "Relay on OK $url, $channel") + // Log.w("Relay", "AUTHSENT Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}") it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString) } "AUTH" -> listeners.forEach { - // Log.w("Relay", "Relay AUTH $url, $channel") + // Log.w("Relay", "Relay AUTHSENT $url, ${msg[1].asString}") it.onAuth(this@Relay, msg[1].asString) } else -> listeners.forEach { @@ -234,11 +235,19 @@ class Relay( } fun send(signedEvent: EventInterface) { - if (write) { - val event = """["EVENT",${signedEvent.toJson()}]""" + if (signedEvent is RelayAuthEvent) { + val event = """["AUTH",${signedEvent.toJson()}]""" socket?.send(event) eventUploadCounterInBytes += event.bytesUsedInMemory() } + + if (write) { + if (signedEvent !is RelayAuthEvent) { + val event = """["EVENT",${signedEvent.toJson()}]""" + socket?.send(event) + eventUploadCounterInBytes += event.bytesUsedInMemory() + } + } } fun close(subscriptionId: String) { From 86fe9b4a65ae468a4adaa98f9b36a1d9e8821738 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 14:22:49 -0400 Subject: [PATCH 076/164] Adds support for NIP-95 Images --- .../amethyst/service/model/FileHeaderEvent.kt | 2 +- .../service/model/FileStorageEvent.kt | 58 +++++ .../service/model/FileStorageHeaderEvent.kt | 67 ++++++ .../amethyst/ui/actions/SaveToGallery.kt | 60 +++++ .../amethyst/ui/components/RichTextViewer.kt | 4 +- .../amethyst/ui/components/VideoView.kt | 4 + .../ui/components/ZoomableContentView.kt | 207 +++++++++++++++--- .../amethyst/ui/note/NoteCompose.kt | 42 +++- 8 files changed, 411 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt index 53988d943..5c91fcc80 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt @@ -48,7 +48,7 @@ class FileHeaderEvent( privateKey: ByteArray, createdAt: Long = Date().time / 1000 ): FileHeaderEvent { - var tags = listOfNotNull( + val tags = listOfNotNull( listOf(URL, url), mimeType?.let { listOf(MIME_TYPE, mimeType) }, hash?.let { listOf(HASH, it) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt new file mode 100644 index 000000000..af25b4152 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt @@ -0,0 +1,58 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Base64 +import java.util.Date + +class FileStorageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) + fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) } + + fun decode(): ByteArray? { + return try { + Base64.getDecoder().decode(content) + } catch (e: Exception) { + Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content") + null + } + } + + companion object { + const val kind = 1064 + + private const val TYPE = "type" + private const val DECRYPT = "decrypt" + + fun encode(bytes: ByteArray): String { + return Base64.getEncoder().encodeToString(bytes) + } + + fun create( + mimeType: String, + data: ByteArray, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): FileStorageEvent { + val tags = listOfNotNull( + listOf(TYPE, mimeType) + ) + + val content = encode(data) + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt new file mode 100644 index 000000000..f7d7f88a8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt @@ -0,0 +1,67 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class FileStorageHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + + companion object { + const val kind = 1065 + + private const val ENCRYPTION_KEY = "aes-256-gcm" + private const val MIME_TYPE = "m" + private const val FILE_SIZE = "size" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + + fun create( + storageEvent: FileStorageEvent, + mimeType: String? = null, + description: String? = null, + hash: String? = null, + size: String? = null, + blurhash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): FileStorageHeaderEvent { + val tags = listOfNotNull( + listOf("e", storageEvent.id), + mimeType?.let { listOf(MIME_TYPE, mimeType) }, + hash?.let { listOf(HASH, it) }, + size?.let { listOf(FILE_SIZE, it) }, + blurhash?.let { listOf(BLUR_HASH, it) }, + magnetURI?.let { listOf(MAGNET_URI, it) }, + torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) } + ) + + val content = description ?: "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} 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 e8dbca2cf..1da278cbc 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 @@ -83,3 +83,63 @@ fun SaveToGallery(url: String) { Text(text = stringResource(id = R.string.save), color = Color.White) } } + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun SaveToGallery(byteArray: ByteArray, mimeType: String?) { + val localContext = LocalContext.current + val scope = rememberCoroutineScope() + + fun saveImage() { + ImageSaver.saveImage( + context = localContext, + byteArray = byteArray, + mimeType = mimeType, + onSuccess = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.image_saved_to_the_gallery), + Toast.LENGTH_SHORT + ) + .show() + } + }, + onError = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.failed_to_save_the_image), + Toast.LENGTH_SHORT + ) + .show() + } + } + ) + } + + val writeStoragePermissionState = rememberPermissionState( + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) { isGranted -> + if (isGranted) { + saveImage() + } + } + + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) { + saveImage() + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = Color.Gray + ) + ) { + Text(text = stringResource(id = R.string.save), color = Color.White) + } +} 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 c2ed2dfb0..ee78c5bee 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 @@ -142,9 +142,9 @@ fun RichTextViewer( val imagesForPager = urlSet.mapNotNull { fullUrl -> val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableImage(fullUrl) + ZoomableUrlImage(fullUrl) } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableVideo(fullUrl) + ZoomableUrlVideo(fullUrl) } else { null } 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 21b0efb53..59c7ec844 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 @@ -95,3 +95,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - } } } + +@Composable +fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index cc840bf07..55292064d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -6,6 +6,7 @@ import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -73,23 +74,47 @@ import net.engawapg.lib.zoomable.zoomable import java.security.MessageDigest abstract class ZoomableContent( - val url: String, - val description: String? = null, - val hash: String? = null + val description: String? = null ) -class ZoomableImage( +abstract class ZoomableUrlContent( + val url: String, + description: String? = null, + val hash: String? = null +) : ZoomableContent(description) + +class ZoomableUrlImage( url: String, description: String? = null, hash: String? = null, val bluehash: String? = null -) : ZoomableContent(url, description, hash) +) : ZoomableUrlContent(url, description, hash) -class ZoomableVideo( +class ZoomableUrlVideo( url: String, description: String? = null, hash: String? = null -) : ZoomableContent(url, description, hash) +) : ZoomableUrlContent(url, description, hash) + +abstract class ZoomablePreloadedContent( + description: String? = null, + val isVerified: Boolean? = null +) : ZoomableContent(description) + +class ZoomableBitmapImage( + val byteArray: ByteArray?, + val mimeType: String? = null, + description: String? = null, + val bluehash: String? = null, + isVerified: Boolean? = null +) : ZoomablePreloadedContent(description, isVerified) + +class ZoomableBytesVideo( + val byteArray: ByteArray, + val mimeType: String? = null, + description: String? = null, + isVerified: Boolean? = null +) : ZoomablePreloadedContent(description, isVerified) fun figureOutMimeType(fullUrl: String): ZoomableContent { val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() @@ -97,11 +122,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent { val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } return if (isImage) { - ZoomableImage(fullUrl) + ZoomableUrlImage(fullUrl) } else if (isVideo) { - ZoomableVideo(fullUrl) + ZoomableUrlVideo(fullUrl) } else { - ZoomableImage(fullUrl) + ZoomableUrlImage(fullUrl) } } @@ -126,15 +151,33 @@ fun ZoomableContentView(content: ZoomableContent, images: List mutableStateOf(null) } - LaunchedEffect(key1 = content.url, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = verifyHash(content, context) + if (content is ZoomableUrlContent) { + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + } else if (content is ZoomableBitmapImage) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } + } + } + } else if (content is ZoomableBytesVideo) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } } } } - val mainImageModifier = Modifier + var mainImageModifier = Modifier .fillMaxWidth() .clip(shape = RoundedCornerShape(15.dp)) .border( @@ -142,12 +185,19 @@ fun ZoomableContentView(content: ZoomableContent, images: List MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp) ) - .combinedClickable( + + if (content is ZoomableUrlContent) { + mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) } ) + } else { + mainImageModifier = mainImageModifier.clickable { + dialogOpen = true + } + } - if (content is ZoomableImage) { + if (content is ZoomableUrlImage) { Box() { AsyncImage( model = content.url, @@ -174,8 +224,36 @@ fun ZoomableContentView(content: ZoomableContent, images: List DisplayUrlWithLoadingSymbol(content) } } - } else { + } else if (content is ZoomableUrlVideo) { VideoView(content.url, content.description) { dialogOpen = true } + } else if (content is ZoomableBitmapImage) { + Box() { + AsyncImage( + model = content.byteArray, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = mainImageModifier, + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState is AsyncImagePainter.State.Success) { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + + if (imageState !is AsyncImagePainter.State.Success) { + if (content.bluehash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } + } else if (content is ZoomableBytesVideo) { } if (dialogOpen) { @@ -185,7 +263,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List @Composable private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { - ClickableUrl(urlText = "${content.url} ", url = content.url) + if (content is ZoomableUrlContent) { + ClickableUrl(urlText = "${content.url} ", url = content.url) + } else { + Text("Loading content... ") + } val myId = "inlineContent" val emptytext = buildAnnotatedString { @@ -220,7 +302,26 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { @Composable private fun DisplayBlueHash( - content: ZoomableImage, + content: ZoomableUrlImage, + modifier: Modifier +) { + if (content.bluehash == null) return + + val context = LocalContext.current + AsyncImage( + model = BlurHashRequester.imageRequest( + context, + content.bluehash + ), + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = modifier + ) +} + +@Composable +private fun DisplayBlueHash( + content: ZoomableBitmapImage, modifier: Modifier ) { if (content.bluehash == null) return @@ -264,7 +365,12 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List 1) { @@ -297,15 +403,33 @@ private fun RenderImageOrVideo(content: ZoomableContent) { mutableStateOf(null) } - LaunchedEffect(key1 = content.url, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = verifyHash(content, context) + if (content is ZoomableUrlContent) { + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + } else if (content is ZoomableBitmapImage) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } + } + } + } else if (content is ZoomableBytesVideo) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } } } } - if (content is ZoomableImage) { + if (content is ZoomableUrlImage) { Box() { AsyncImage( model = content.url, @@ -327,15 +451,42 @@ private fun RenderImageOrVideo(content: ZoomableContent) { HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) } } - } else { + } else if (content is ZoomableUrlVideo) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { VideoView(content.url, content.description) } + } else if (content is ZoomableBitmapImage) { + Box() { + AsyncImage( + model = content.byteArray, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .zoomable(rememberZoomState()), + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState !is AsyncImagePainter.State.Success) { + DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth()) + } else { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + } else if (content is ZoomableBytesVideo) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + VideoView(content.byteArray, content.description) + } } } @OptIn(ExperimentalCoilApi::class) -private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? { +private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? { if (content.hash == null) return null context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> 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 cb53c7175..7f463a827 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 @@ -169,6 +169,8 @@ fun NoteComposeInner( BadgeDisplay(baseNote = note) } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(note) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(note) } else { var isNew by remember { mutableStateOf(false) } @@ -794,9 +796,9 @@ fun FileHeaderDisplay(note: Note) { val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } content = if (isImage) { - ZoomableImage(fullUrl, description, hash, blurHash) + ZoomableUrlImage(fullUrl, description, hash, blurHash) } else { - ZoomableVideo(fullUrl, description, hash) + ZoomableUrlVideo(fullUrl, description, hash) } } } @@ -806,6 +808,42 @@ fun FileHeaderDisplay(note: Note) { } ?: UrlPreview(fullUrl, "$fullUrl ") } +@Composable +fun FileStorageHeaderDisplay(baseNote: Note) { + val fileNote = baseNote.replyTo?.firstOrNull() ?: return + + val noteState by fileNote.live().metadata.observeAsState() + val note = noteState?.note + + val eventBytes = (note?.event as? FileStorageEvent) + val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return + + var content by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = eventHeader.id) { + withContext(Dispatchers.IO) { + val bytes = eventBytes?.decode() + val blurHash = eventHeader.blurhash() + val description = eventHeader.content + val mimeType = eventHeader.mimeType() + + content = if (mimeType?.startsWith("image") == true) { + ZoomableBitmapImage(bytes, mimeType, description, blurHash, true) + } else { + if (bytes != null) { + ZoomableBytesVideo(bytes, mimeType, description, true) + } else { + null + } + } + } + } + + content?.let { + ZoomableContentView(content = it, listOf(it)) + } ?: BlankNote() +} + @Composable private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { Row( From a669e3777427c84730c02e156ed4ac4f134fd159 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 14:23:06 -0400 Subject: [PATCH 077/164] Adds server selection support --- .../amethyst/LocalPreferences.kt | 9 +++ .../vitorpamplona/amethyst/model/Account.kt | 8 ++ .../amethyst/ui/actions/ImageSaver.kt | 36 +++++++++ .../amethyst/ui/actions/ImageUploader.kt | 1 + .../amethyst/ui/actions/NewPostView.kt | 76 ++++++++++++++----- .../amethyst/ui/actions/NewPostViewModel.kt | 3 +- .../ui/actions/NewUserMetadataViewModel.kt | 1 + .../ui/screen/loggedIn/ChannelScreen.kt | 2 +- .../ui/screen/loggedIn/ChatroomScreen.kt | 2 +- app/src/main/res/values/strings.xml | 6 +- 10 files changed, 120 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index f71ef1439..09e7bf954 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.ui.actions.ServersAvailable import com.vitorpamplona.amethyst.ui.note.Nip47URI import fr.acinq.secp256k1.Hex import nostr.postr.Persona @@ -44,6 +45,7 @@ private object PrefKeys { const val TRANSLATE_TO = "translateTo" const val ZAP_AMOUNTS = "zapAmounts" const val DEFAULT_ZAPTYPE = "defaultZapType" + const val DEFAULT_FILE_SERVER = "defaultFileServer" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" @@ -194,6 +196,7 @@ object LocalPreferences { putString(PrefKeys.TRANSLATE_TO, account.translateTo) putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices)) putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType)) + putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer)) putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) @@ -225,6 +228,11 @@ object LocalPreferences { object : TypeToken() {}.type ) ?: LnZapEvent.ZapType.PUBLIC + val defaultFileServer = gson.fromJson( + getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"), + object : TypeToken() {}.type + ) ?: ServersAvailable.IMGUR + val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { gson.fromJson(it, Nip47URI::class.java) @@ -269,6 +277,7 @@ object LocalPreferences { translateTo, zapAmountChoices, defaultZapType, + defaultFileServer, zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, 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 e09112dae..428656963 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -11,6 +11,7 @@ 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 com.vitorpamplona.amethyst.ui.actions.ServersAvailable import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.note.Nip47URI import kotlinx.coroutines.DelicateCoroutinesApi @@ -45,6 +46,7 @@ class Account( var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, + var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -652,6 +654,12 @@ class Account( saveable.invalidateData() } + fun changeDefaultFileServer(server: ServersAvailable) { + defaultFileServer = server + live.invalidateData() + saveable.invalidateData() + } + fun changeZapAmounts(newAmounts: List) { zapAmountChoices = newAmounts live.invalidateData() 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 485f21062..ba62883c4 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 @@ -7,13 +7,17 @@ import android.media.MediaScannerConnection import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import com.vitorpamplona.amethyst.BuildConfig import okhttp3.* import okio.BufferedSource import okio.IOException +import okio.buffer import okio.sink +import okio.source import java.io.File +import java.util.UUID object ImageSaver { /** @@ -74,6 +78,38 @@ object ImageSaver { }) } + fun saveImage( + byteArray: ByteArray, + mimeType: String?, + context: Context, + onSuccess: () -> Any?, + onError: (Throwable) -> Any? + ) { + try { + val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + val buffer = byteArray.inputStream().source().buffer() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveContentQ( + displayName = UUID.randomUUID().toString(), + contentType = mimeType ?: "", + contentSource = buffer, + contentResolver = context.contentResolver + ) + } else { + saveContentDefault( + fileName = UUID.randomUUID().toString() + ".$extension", + contentSource = buffer, + context = context + ) + } + onSuccess() + } catch (e: Exception) { + e.printStackTrace() + onError(e) + } + } + @RequiresApi(Build.VERSION_CODES.Q) private fun saveContentQ( displayName: String, 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 cf15b8c3a..ffc78709c 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 @@ -14,6 +14,7 @@ import java.util.* object ImageUploader { fun uploadImage( uri: Uri, + server: ServersAvailable, contentResolver: ContentResolver, onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit 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 37904090f..e99552cd1 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 @@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers @@ -206,8 +207,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n if (url != null) { ImageVideoDescription( url, - onAdd = { description -> - postViewModel.upload(url, description, context) + account.defaultFileServer, + onAdd = { description, server -> + postViewModel.upload(url, description, server, context) + account.changeDefaultFileServer(server) }, onCancel = { postViewModel.contentToAddUrl = null @@ -485,10 +488,15 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } } +enum class ServersAvailable() { + IMGUR +} + @Composable fun ImageVideoDescription( uri: Uri, - onAdd: (String) -> Unit, + defaultServer: ServersAvailable, + onAdd: (String, ServersAvailable) -> Unit, onCancel: () -> Unit ) { val resolver = LocalContext.current.contentResolver @@ -498,6 +506,15 @@ fun ImageVideoDescription( val isImage = mediaType.startsWith("image") val isVideo = mediaType.startsWith("video") + val fileServers = listOf( + Pair(ServersAvailable.IMGUR, "imgur.com") + ) + + val fileServerOptions = fileServers.map { it.second } + + var selectedServer by remember { mutableStateOf(defaultServer) } + var message by remember { mutableStateOf("") } + Column( modifier = Modifier .fillMaxWidth() @@ -595,30 +612,49 @@ fun ImageVideoDescription( } } - var message by remember { mutableStateOf("") } - - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth(), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + TextSpinner( + label = stringResource(id = R.string.file_server), + placeholder = fileServers.filter { it.first == defaultServer }.first().second, + options = fileServerOptions, + onSelect = { + selectedServer = fileServers[it].first + }, + modifier = Modifier + .weight(1f) ) - ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ) + ) + } Button( modifier = Modifier .fillMaxWidth() .padding(vertical = 10.dp), onClick = { - onAdd(message) + onAdd(message, selectedServer) }, shape = RoundedCornerShape(15.dp), colors = ButtonDefaults.buttonColors( 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 a07701358..77ff7a0ac 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 @@ -110,12 +110,13 @@ open class NewPostViewModel : ViewModel() { cancel() } - fun upload(it: Uri, description: String, context: Context) { + fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) { isUploadingImage = true contentToAddUrl = null ImageUploader.uploadImage( uri = it, + server = server, contentResolver = context.contentResolver, onSuccess = { imageUrl, mimeType -> createNIP97Record(imageUrl, mimeType, description) 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 5a252ef02..269f22871 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 @@ -170,6 +170,7 @@ class NewUserMetadataViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, + server = account.defaultFileServer, contentResolver = context.contentResolver, onSuccess = { imageUrl, mimeType -> onUploading(false) 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 fe3602a31..63e0e4d92 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 @@ -233,7 +233,7 @@ fun ChannelScreen( tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - channelScreenModel.upload(it, "", context) + channelScreenModel.upload(it, "", account.defaultFileServer, context) } }, colors = TextFieldDefaults.textFieldColors( 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 5dd3bb2ca..cf75e066a 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 @@ -194,7 +194,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - chatRoomScreenModel.upload(it, "", context) + chatRoomScreenModel.upload(it, "", account.defaultFileServer, context) } }, colors = TextFieldDefaults.textFieldColors( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bbab09bf3..5fb553efd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,7 +303,8 @@ Add Image Add Video Add Document - Create and Add + + Add to Message Description of the contents A blue boat in a white sandy beach at sunset @@ -322,4 +323,7 @@ Non-Zap No trace in Nostr, only in Lightning + + File Server + From e370e75ba42f23f42f34712e2d93408b98e3b8c1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 16:36:52 -0400 Subject: [PATCH 078/164] Offers NostrImg as a choice of image server --- .../amethyst/ImageUploadTesting.kt | 91 +++++++++++++++ .../service/NostrSingleEventDataSource.kt | 3 +- .../amethyst/ui/actions/ImageUploader.kt | 107 +++++++++++++++--- .../amethyst/ui/actions/NewPostView.kt | 9 +- 4 files changed, 190 insertions(+), 20 deletions(-) create mode 100644 app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt new file mode 100644 index 000000000..c8803f82d --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -0,0 +1,91 @@ +package com.vitorpamplona.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.ui.actions.ImageUploader +import com.vitorpamplona.amethyst.ui.actions.ImgurServer +import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer +import com.vitorpamplona.amethyst.ui.actions.NostrImgServer +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.fail +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Base64 + +@RunWith(AndroidJUnit4::class) +class ImageUploadTesting { + + val image = "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" + + @Test() + fun testImgurUpload() = runBlocking { + val inputStream = Base64.getDecoder().decode(image).inputStream() + + println("Uploading") + + ImageUploader.uploadImage( + inputStream, + "image/gif", + ImgurServer(), + onSuccess = { url, contentType -> + println("Uploaded to $url") + assertNotNull(url) + }, + onError = { + println("Failed to Upload") + fail("${it.message}") + } + ) + + delay(1000) + } + + @Test() + @Ignore + fun testNostrBuildUpload() = runBlocking { + val inputStream = Base64.getDecoder().decode(image).inputStream() + + println("Uploading") + + ImageUploader.uploadImage( + inputStream, + "image/gif", + NostrBuildServer(), + onSuccess = { url, contentType -> + println("Uploaded to $url") + assertNotNull(url) + }, + onError = { + println("Failed to Upload") + fail("${it.message}") + } + ) + + delay(1000) + } + + @Test() + fun testNostrImgUpload() = runBlocking { + val inputStream = Base64.getDecoder().decode(image).inputStream() + + println("Uploading") + + ImageUploader.uploadImage( + inputStream, + "image/gif", + NostrImgServer(), + onSuccess = { url, contentType -> + println("Uploaded to $url") + assertNotNull(url) + }, + onError = { + println("Failed to Upload") + fail("${it.message}") + } + ) + + delay(1000) + } +} 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 61c45d61a..85746e500 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -116,7 +116,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { LnZapEvent.kind, LnZapRequestEvent.kind, ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, - PrivateDmEvent.kind, FileHeaderEvent.kind + PrivateDmEvent.kind, + FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind ), ids = interestedEvents.toList() ) 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 ffc78709c..6238ddf9a 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 @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.ContentResolver import android.net.Uri +import android.webkit.MimeTypeMap import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig import okhttp3.* @@ -9,9 +10,15 @@ import okhttp3.MediaType.Companion.toMediaType import okio.BufferedSink import okio.source import java.io.IOException +import java.io.InputStream import java.util.* +val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + +fun randomChars() = List(16) { charPool.random() }.joinToString("") + object ImageUploader { + fun uploadImage( uri: Uri, server: ServersAvailable, @@ -20,9 +27,33 @@ object ImageUploader { onError: (Throwable) -> Unit ) { val contentType = contentResolver.getType(uri) - val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + val imageInputStream = contentResolver.openInputStream(uri) - val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload" + checkNotNull(imageInputStream) { + "Can't open the image input stream" + } + + val myServer = if (server == ServersAvailable.IMGUR) { + ImgurServer() + } else if (server == ServersAvailable.NOSTR_IMG) { + NostrImgServer() + } else { + ImgurServer() + } + + uploadImage(imageInputStream, contentType, myServer, onSuccess, onError) + } + + fun uploadImage( + inputStream: InputStream, + contentType: String?, + server: FileServer, + onSuccess: (String, String?) -> Unit, + onError: (Throwable) -> Unit + ) { + val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + val fileName = randomChars() + val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = OkHttpClient.Builder().build() @@ -30,37 +61,37 @@ object ImageUploader { .setType(MultipartBody.FORM) .addFormDataPart( category, - "${UUID.randomUUID()}", + "$fileName.$extension", 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" - } - - imageInputStream.source().use(sink::writeAll) + inputStream.source().use(sink::writeAll) } } ) .build() - val request: Request = Request.Builder() - .header("Authorization", "Client-ID e6aea87296f3f96") + val requestBuilder = Request.Builder() + + server.clientID()?.let { + requestBuilder.header("Authorization", it) + } + + requestBuilder .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) + .url(server.postUrl(contentType)) .post(requestBody) - .build() + + val request = requestBuilder.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() + val url = server.parseUrlFromSucess(body.string()) checkNotNull(url) { "There must be an uploaded image URL in the response" } @@ -80,3 +111,49 @@ object ImageUploader { }) } } + +abstract class FileServer { + abstract fun postUrl(contentType: String?): String + abstract fun parseUrlFromSucess(body: String): String? + + open fun clientID(): String? = null +} + +class NostrImgServer : FileServer() { + override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload" + + override fun parseUrlFromSucess(body: String): String? { + val tree = jacksonObjectMapper().readTree(body) + val url = tree?.get("data")?.get("link")?.asText() + return url + } + + override fun clientID() = null +} + +class ImgurServer : FileServer() { + override fun postUrl(contentType: String?): String { + val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload" + } + + override fun parseUrlFromSucess(body: String): String? { + val tree = jacksonObjectMapper().readTree(body) + val url = tree?.get("data")?.get("link")?.asText() + return url + } + + override fun clientID() = "Client-ID e6aea87296f3f96" +} + +class NostrBuildServer : FileServer() { + override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/amethyst.php" + + override fun parseUrlFromSucess(body: String): String? { + val tree = jacksonObjectMapper().readTree(body) + val url = tree?.get("data")?.get("link")?.asText() + return url + } + + override fun clientID() = null +} 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 e99552cd1..7180b3b17 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 @@ -488,8 +488,8 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } } -enum class ServersAvailable() { - IMGUR +enum class ServersAvailable { + IMGUR, NOSTR_BUILD, NOSTR_IMG } @Composable @@ -507,7 +507,8 @@ fun ImageVideoDescription( val isVideo = mediaType.startsWith("video") val fileServers = listOf( - Pair(ServersAvailable.IMGUR, "imgur.com") + Pair(ServersAvailable.IMGUR, "imgur.com"), + Pair(ServersAvailable.NOSTR_IMG, "nostrimg.com") ) val fileServerOptions = fileServers.map { it.second } @@ -618,7 +619,7 @@ fun ImageVideoDescription( ) { TextSpinner( label = stringResource(id = R.string.file_server), - placeholder = fileServers.filter { it.first == defaultServer }.first().second, + placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second, options = fileServerOptions, onSelect = { selectedServer = fileServers[it].first From ab747f5e933f8b4583c4055efd4aa55782b94e3b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 18:04:38 -0400 Subject: [PATCH 079/164] Activating NIP-95 support --- .../vitorpamplona/amethyst/model/Account.kt | 28 ++++++++ .../amethyst/model/LocalCache.kt | 30 +++++++++ .../amethyst/service/FileHeader.kt | 13 ++-- .../amethyst/service/NostrDataSource.kt | 2 + .../amethyst/service/model/Event.kt | 2 + .../service/model/FileStorageHeaderEvent.kt | 2 + .../amethyst/service/relays/Relay.kt | 12 ++-- .../amethyst/ui/actions/NewPostView.kt | 5 +- .../amethyst/ui/actions/NewPostViewModel.kt | 65 +++++++++++++++---- .../amethyst/ui/note/NoteCompose.kt | 7 +- 10 files changed, 136 insertions(+), 30 deletions(-) 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 428656963..de76b406e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -375,6 +375,34 @@ class Account( } } + fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? { + if (!isWriteable()) return null + + val data = FileStorageEvent.create( + mimeType = headerInfo.mimeType ?: "", + data = data, + privateKey = loggedIn.privKey!! + ) + + val signedEvent = FileStorageHeaderEvent.create( + data, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + blurhash = headerInfo.blurHash, + description = headerInfo.description, + privateKey = loggedIn.privKey!! + ) + + Client.send(data) + LocalCache.consume(data) + + Client.send(signedEvent) + LocalCache.consume(signedEvent) + + return LocalCache.notes[signedEvent.id] + } + fun sendHeader(headerInfo: FileHeader): Note? { if (!isWriteable()) 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 1b489bc41..fccf22bea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -666,6 +666,32 @@ object LocalCache { refreshObservers(note) } + fun consume(event: FileStorageHeaderEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume(event: FileStorageEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + fun consume(event: LnZapPaymentRequestEvent) { // Does nothing without a response callback. } @@ -723,6 +749,10 @@ object LocalCache { } } + fun pruneFileStorageEvents(account: Account) { + notes.filter { it.value.event is FileStorageEvent } + } + fun pruneOldAndHiddenMessages(account: Account) { channels.forEach { it -> val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index c4e6ff8ee..4c21971e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -19,17 +19,22 @@ class FileHeader( ) { companion object { fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { + val imageData = URL(fileUrl).readBytes() + + prepare(imageData, fileUrl, mimeType, description, onReady, onError) + } + + fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { try { - val imageData = URL(fileUrl).readBytes() val sha256 = MessageDigest.getInstance("SHA-256") - val hash = sha256.digest(imageData).toHexKey() - val size = imageData.size + val hash = sha256.digest(data).toHexKey() + val size = data.size val blurHash = if (mimeType?.startsWith("image/") == true) { val opt = BitmapFactory.Options() opt.inPreferredConfig = Bitmap.Config.ARGB_8888 - val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt) + val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) val intArray = IntArray(mBitmap.width * mBitmap.height) mBitmap.getPixels( 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 bdabd984f..b5789a178 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -77,6 +77,8 @@ abstract class NostrDataSource(val debugName: String) { is DeletionEvent -> LocalCache.consume(event) is FileHeaderEvent -> LocalCache.consume(event) + is FileStorageEvent -> LocalCache.consume(event) + is FileStorageHeaderEvent -> LocalCache.consume(event) is LnZapEvent -> { event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) 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 9b63fa799..151f17621 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 @@ -223,6 +223,8 @@ open class Event( DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt index f7d7f88a8..ae2fd5cdf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt @@ -14,6 +14,8 @@ class FileStorageHeaderEvent( sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) 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 653a1fdf6..274fd675c 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 @@ -96,8 +96,6 @@ class Relay( val type = msg[0].asString val channel = msg[1].asString - // Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}") - when (type) { "EVENT" -> { val event = Event.fromJson(msg[2], Client.lenient) @@ -116,15 +114,15 @@ class Relay( it.onRelayStateChange(this@Relay, Type.EOSE, channel) } "NOTICE" -> listeners.forEach { - // Log.w("Relay", "Relay onNotice $url, $channel") + Log.w("Relay", "Relay onNotice $url, $channel") it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - // Log.w("Relay", "AUTHSENT Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}") + Log.w("Relay", "Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}") it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString) } "AUTH" -> listeners.forEach { - // Log.w("Relay", "Relay AUTHSENT $url, ${msg[1].asString}") + // Log.w("Relay", "Relay$url, ${msg[1].asString}") it.onAuth(this@Relay, msg[1].asString) } else -> listeners.forEach { @@ -207,8 +205,8 @@ class Relay( val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() } if (filters.isNotEmpty()) { val request = - """["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]""" - // println("FILTERSSENT $url $request") + """["REQ","$requestId",${filters.take(12).joinToString(",") { it.filter.toJson(url) }}]""" + //println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() afterEOSE = false 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 7180b3b17..2350c0907 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 @@ -489,7 +489,7 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } enum class ServersAvailable { - IMGUR, NOSTR_BUILD, NOSTR_IMG + IMGUR, NOSTR_BUILD, NOSTR_IMG, NIP95 } @Composable @@ -508,7 +508,8 @@ fun ImageVideoDescription( val fileServers = listOf( Pair(ServersAvailable.IMGUR, "imgur.com"), - Pair(ServersAvailable.NOSTR_IMG, "nostrimg.com") + Pair(ServersAvailable.NOSTR_IMG, "nostrimg.com"), + Pair(ServersAvailable.NIP95, "your relays (NIP-95)") ) val fileServerOptions = fileServers.map { it.second } 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 77ff7a0ac..8494fe4d0 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 @@ -114,20 +114,29 @@ open class NewPostViewModel : ViewModel() { isUploadingImage = true contentToAddUrl = null - ImageUploader.uploadImage( - uri = it, - server = server, - contentResolver = context.contentResolver, - onSuccess = { imageUrl, mimeType -> - createNIP97Record(imageUrl, mimeType, description) - }, - onError = { - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } + val contentResolver = context.contentResolver + + if (server == ServersAvailable.NIP95) { + val contentType = contentResolver.getType(it) + contentResolver.openInputStream(it)?.use { + createNIP95Record(it.readBytes(), contentType, description) } - ) + } else { + ImageUploader.uploadImage( + uri = it, + server = server, + contentResolver = contentResolver, + onSuccess = { imageUrl, mimeType -> + createNIP94Record(imageUrl, mimeType, description) + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } } open fun cancel() { @@ -212,7 +221,7 @@ open class NewPostViewModel : ViewModel() { } } - fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) { + fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) { viewModelScope.launch(Dispatchers.IO) { // Images don't seem to be ready immediately after upload @@ -249,6 +258,34 @@ open class NewPostViewModel : ViewModel() { } } + fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) { + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + "", + mimeType, + description, + onReady = { + val note = account?.sendNip95(bytes, headerInfo = it) + + isUploadingImage = false + + note?.let { + message = TextFieldValue(message.text + "\n\nnostr:" + it.toNEvent()) + } + + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + fun selectImage(uri: Uri) { contentToAddUrl = uri } 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 7f463a827..bdc8aba6f 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 @@ -810,17 +810,18 @@ fun FileHeaderDisplay(note: Note) { @Composable fun FileStorageHeaderDisplay(baseNote: Note) { - val fileNote = baseNote.replyTo?.firstOrNull() ?: return + val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return + + val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return val noteState by fileNote.live().metadata.observeAsState() val note = noteState?.note val eventBytes = (note?.event as? FileStorageEvent) - val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return var content by remember { mutableStateOf(null) } - LaunchedEffect(key1 = eventHeader.id) { + LaunchedEffect(key1 = eventHeader.id, key2 = noteState) { withContext(Dispatchers.IO) { val bytes = eventBytes?.decode() val blurHash = eventHeader.blurhash() From 04e4c4692f772bd2acb750c53b036d61f4081166 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 18:05:02 -0400 Subject: [PATCH 080/164] disabling logs --- .../java/com/vitorpamplona/amethyst/service/relays/Relay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 274fd675c..60b7cb31b 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 @@ -206,7 +206,7 @@ class Relay( if (filters.isNotEmpty()) { val request = """["REQ","$requestId",${filters.take(12).joinToString(",") { it.filter.toJson(url) }}]""" - //println("FILTERSSENT $url $request") + // println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() afterEOSE = false From c60ad472e83d0de09d7758f85a9b45d8394fa392 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 18:05:19 -0400 Subject: [PATCH 081/164] v0.37.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 680e8c2af..27f9c25bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 126 - versionName "0.36.0" + versionCode 127 + versionName "0.37.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From d4a3ee26518decb2911cf9400bd842fbde8373eb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 19:04:01 -0400 Subject: [PATCH 082/164] Adding nip95 to readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c6d9da94b..3192471aa 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Badges (NIP-58) - [x] Hashtag Following and Custom Hashtags - [x] Polls (NIP-69) -- [x] Verifiable uploads (NIP-94) +- [x] Verifiable static content in URLs (NIP-94) - [x] Login with QR - [x] Wallet Connect API (NIP-47) - [x] Accessible uploads @@ -53,6 +53,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Multiple Accounts - [x] Markdown Support - [x] Relay Authentication (NIP-42) +- [x] Content stored in relays themselves (NIP-95) - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) From 1596a7b5597fff66ce4f2a2cad7f7b58db1cfe4b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 09:26:26 -0400 Subject: [PATCH 083/164] Pruning file storage events when pausing the app. --- .../main/java/com/vitorpamplona/amethyst/ServiceManager.kt | 1 + .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index fbff99b63..3810ddd2c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -72,6 +72,7 @@ object ServiceManager { LocalCache.pruneOldAndHiddenMessages(it) LocalCache.pruneHiddenMessages(it) LocalCache.pruneContactLists(it) + LocalCache.pruneFileStorageEvents(it) // LocalCache.pruneNonFollows(it) } } 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 fccf22bea..d2e76ffab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -750,7 +750,11 @@ object LocalCache { } fun pruneFileStorageEvents(account: Account) { - notes.filter { it.value.event is FileStorageEvent } + val toBeRemoved = notes.filter { it.value.event is FileStorageEvent } + + toBeRemoved.forEach { + notes.remove(it.key) + } } fun pruneOldAndHiddenMessages(account: Account) { From 0a11de18927facfdfa4e0374465f3b28e9543a7e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 09:27:29 -0400 Subject: [PATCH 084/164] Adds Zap Address preference to the zap tag https://github.com/nostr-protocol/nips/pull/402/files --- .../main/java/com/vitorpamplona/amethyst/service/model/Event.kt | 2 ++ .../com/vitorpamplona/amethyst/service/model/EventInterface.kt | 2 ++ .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) 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 151f17621..33c09bbe5 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 @@ -41,6 +41,8 @@ open class Event( fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1) + fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { val aTagValue = it[1] val relay = it.getOrNull(2) 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 709a7dacd..5a4e1db57 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 @@ -33,4 +33,6 @@ interface EventInterface { fun getReward(): BigDecimal? fun getPoWRank(): Int + + fun zapAddress(): String? } 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 8cce0da97..cad01018d 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 @@ -60,7 +60,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { } fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { - val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() + val lud16 = note.event?.zapAddress() ?: 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)) From f10518873841d0e96172feafbb0ee8445fdbb934 Mon Sep 17 00:00:00 2001 From: vivganes Date: Thu, 27 Apr 2023 21:13:25 +0530 Subject: [PATCH 085/164] Add support for non-english hashtags too --- .../com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ee78c5bee..25e05d8eb 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 @@ -59,7 +59,7 @@ val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\ val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)") -val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE) +val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) val urlPattern: Pattern = Patterns.WEB_URL fun isValidURL(url: String?): Boolean { From 2ca877b3c3e0b855f4a9bd03c26783ceefe1c46d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 14:43:28 -0400 Subject: [PATCH 086/164] Zap Forwarding --- .../vitorpamplona/amethyst/model/Account.kt | 15 ++- .../service/model/ChannelMessageEvent.kt | 5 +- .../amethyst/service/model/PollNoteEvent.kt | 8 +- .../amethyst/service/model/PrivateDmEvent.kt | 4 + .../amethyst/service/model/TextNoteEvent.kt | 5 + .../amethyst/ui/actions/NewPostView.kt | 113 +++++++++++++++--- .../amethyst/ui/actions/NewPostViewModel.kt | 85 ++++++++++--- app/src/main/res/values/strings.xml | 2 +- 8 files changed, 196 insertions(+), 41 deletions(-) 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 de76b406e..2a1cfc26f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -422,7 +422,7 @@ class Account( return LocalCache.notes[signedEvent.id] } - fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null, zapReceiver: String? = null) { if (!isWriteable()) return val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } @@ -435,6 +435,7 @@ class Account( mentions = mentionsHex, addresses = addresses, extraTags = tags, + zapReceiver = zapReceiver, privateKey = loggedIn.privKey!! ) @@ -450,7 +451,8 @@ class Account( valueMaximum: Int?, valueMinimum: Int?, consensusThreshold: Int?, - closedAt: Int? + closedAt: Int?, + zapReceiver: String? = null ) { if (!isWriteable()) return @@ -468,14 +470,15 @@ class Account( valueMaximum = valueMaximum, valueMinimum = valueMinimum, consensusThreshold = consensusThreshold, - closedAt = closedAt + closedAt = closedAt, + zapReceiver = zapReceiver ) // println("Sending new PollNoteEvent: %s".format(signedEvent.toJson())) Client.send(signedEvent) LocalCache.consume(signedEvent) } - fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?) { + fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?, zapReceiver: String? = null) { if (!isWriteable()) return // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } @@ -487,13 +490,14 @@ class Account( channel = toChannel, replyTos = repliesToHex, mentions = mentionsHex, + zapReceiver = zapReceiver, privateKey = loggedIn.privKey!! ) Client.send(signedEvent) LocalCache.consume(signedEvent, null) } - fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?) { + fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?, zapReceiver: String? = null) { if (!isWriteable()) return val user = LocalCache.users[toUser] ?: return @@ -506,6 +510,7 @@ class Account( msg = message, replyTos = repliesToHex, mentions = mentionsHex, + zapReceiver = zapReceiver, privateKey = loggedIn.privKey!!, advertiseNip18 = false ) 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 e2db58fbd..5fb98d1e3 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 @@ -20,7 +20,7 @@ class ChannelMessageEvent( 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 { + fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent { val content = message val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = mutableListOf( @@ -32,6 +32,9 @@ class ChannelMessageEvent( mentions?.forEach { tags.add(listOf("p", it)) } + zapReceiver?.let { + tags.add(listOf("zap", it)) + } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privateKey) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt index c91cd4c7a..0cb71f805 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt @@ -48,7 +48,8 @@ class PollNoteEvent( valueMaximum: Int?, valueMinimum: Int?, consensusThreshold: Int?, - closedAt: Int? + closedAt: Int?, + zapReceiver: String? ): PollNoteEvent { val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = mutableListOf>() @@ -68,6 +69,11 @@ class PollNoteEvent( tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString())) tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) tags.add(listOf(CLOSED_AT, closedAt.toString())) + + if (zapReceiver != null) { + tags.add(listOf("zap", zapReceiver)) + } + val id = generateId(pubKey, createdAt, kind, tags, msg) val sig = Utils.sign(id, privateKey) return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) 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 c820d0ca2..c45c27848 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 @@ -58,6 +58,7 @@ class PrivateDmEvent( msg: String, replyTos: List? = null, mentions: List? = null, + zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000, publishedRecipientPubKey: ByteArray? = null, @@ -79,6 +80,9 @@ class PrivateDmEvent( mentions?.forEach { tags.add(listOf("p", it)) } + zapReceiver?.let { + tags.add(listOf("zap", it)) + } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privateKey) return PrivateDmEvent(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 f64582ba3..b9bc6ab55 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 @@ -26,6 +26,7 @@ class TextNoteEvent( mentions: List?, addresses: List?, extraTags: List?, + zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000 ): TextNoteEvent { @@ -46,9 +47,13 @@ class TextNoteEvent( extraTags?.forEach { tags.add(listOf("t", it)) } + zapReceiver?.let { + tags.add(listOf("zap", it)) + } findURLs(msg).forEach { tags.add(listOf("r", it)) } + val id = generateId(pubKey, createdAt, kind, tags, msg) val sig = Utils.sign(id, privateKey) return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) 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 2350c0907..17d67d7ec 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 @@ -17,8 +17,12 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CurrencyBitcoin +import androidx.compose.material.icons.outlined.ArrowForwardIos +import androidx.compose.material.icons.outlined.Bolt import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -205,17 +209,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n val url = postViewModel.contentToAddUrl if (url != null) { - ImageVideoDescription( - url, - account.defaultFileServer, - onAdd = { description, server -> - postViewModel.upload(url, description, server, context) - account.changeDefaultFileServer(server) - }, - onCancel = { - postViewModel.contentToAddUrl = null - } - ) + Row(verticalAlignment = Alignment.CenterVertically) { + ImageVideoDescription( + url, + account.defaultFileServer, + onAdd = { description, server -> + postViewModel.upload(url, description, server, context) + account.changeDefaultFileServer(server) + }, + onCancel = { + postViewModel.contentToAddUrl = null + } + ) + } } val user = postViewModel.account?.userProfile() @@ -301,11 +307,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } - Row(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().height(50.dp), verticalAlignment = Alignment.CenterVertically) { UploadFromGallery( isUploading = postViewModel.isUploadingImage, tint = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(bottom = 10.dp) + modifier = Modifier ) { postViewModel.selectImage(it) } @@ -323,6 +329,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } + + ForwardZapTo(postViewModel) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } } } } @@ -386,6 +396,71 @@ private fun AddLnInvoiceButton( } } +@Composable +private fun ForwardZapTo( + postViewModel: NewPostViewModel, + onClick: () -> Unit +) { + IconButton( + onClick = { + onClick() + } + ) { + Box(Modifier.height(20.dp).width(25.dp)) { + if (!postViewModel.wantsForwardZapTo) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = MaterialTheme.colors.onBackground + ) + Icon( + imageVector = Icons.Default.ArrowForwardIos, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = MaterialTheme.colors.onBackground + ) + } else { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = BitcoinOrange + ) + Icon( + imageVector = Icons.Outlined.ArrowForwardIos, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = BitcoinOrange + ) + } + } + } + + if (postViewModel.wantsForwardZapTo) { + OutlinedTextField( + value = postViewModel.forwardZapToEditting, + onValueChange = { + postViewModel.updateZapForwardTo(it) + }, + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).padding(0.dp), + placeholder = { + Text( + text = stringResource(R.string.zap_forward_lnAddress), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + colors = TextFieldDefaults + .outlinedTextFieldColors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } +} + @Composable fun CloseButton(onCancel: () -> Unit) { Button( @@ -555,7 +630,7 @@ fun ImageVideoDescription( fontWeight = FontWeight.W500, modifier = Modifier .padding(start = 10.dp) - .weight(1.0f) + .weight(1.0f).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) IconButton( @@ -579,7 +654,7 @@ fun ImageVideoDescription( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp) + .padding(bottom = 10.dp).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { if (mediaType.startsWith("image")) { AsyncImage( @@ -588,7 +663,7 @@ fun ImageVideoDescription( contentScale = ContentScale.FillWidth, modifier = Modifier .padding(top = 4.dp) - .fillMaxWidth() + .fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { var bitmap by remember { mutableStateOf(null) } @@ -625,18 +700,18 @@ fun ImageVideoDescription( onSelect = { selectedServer = fileServers[it].first }, - modifier = Modifier + modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) .weight(1f) ) } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { OutlinedTextField( label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), value = message, onValueChange = { message = it }, placeholder = { 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 8494fe4d0..267f8f576 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 @@ -37,6 +37,7 @@ open class NewPostViewModel : ViewModel() { var userSuggestions by mutableStateOf>(emptyList()) var userSuggestionAnchor: TextRange? = null + var userSuggestionsMainMessage: Boolean? = null // Images and Videos var contentToAddUrl by mutableStateOf(null) @@ -61,6 +62,11 @@ open class NewPostViewModel : ViewModel() { var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + var forwardZapTo by mutableStateOf(null) + var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + open fun load(account: Account, replyingTo: Note?, quote: Note?) { originalNote = replyingTo replyingTo?.let { replyNote -> @@ -90,6 +96,10 @@ open class NewPostViewModel : ViewModel() { canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null contentToAddUrl = null + wantsForwardZapTo = false + forwardZapTo = null + forwardZapToEditting = TextFieldValue("") + this.account = account } @@ -97,14 +107,24 @@ open class NewPostViewModel : ViewModel() { val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text) tagger.run() - if (wantsPoll) { - account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt) - } else if (originalNote?.channel() != null) { - account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions) - } else if (originalNote?.event is PrivateDmEvent) { - account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions) + val zapReceiver = if (wantsForwardZapTo) { + if (forwardZapTo != null) { + forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06 + } else { + forwardZapToEditting.text + } } else { - account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions) + null + } + + if (wantsPoll) { + account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver) + } else if (originalNote?.channel() != null) { + account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver) + } else if (originalNote?.event is PrivateDmEvent) { + account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver) + } else { + account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver) } cancel() @@ -155,6 +175,14 @@ open class NewPostViewModel : ViewModel() { closedAt = null wantsInvoice = false + + wantsForwardZapTo = false + forwardZapTo = null + forwardZapToEditting = TextFieldValue("") + + userSuggestions = emptyList() + userSuggestionAnchor = null + userSuggestionsMainMessage = null } open fun findUrlInMessage(): String? { @@ -176,6 +204,21 @@ open class NewPostViewModel : ViewModel() { if (it.selection.collapsed) { val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") userSuggestionAnchor = it.selection + userSuggestionsMainMessage = true + if (lastWord.startsWith("@") && lastWord.length > 2) { + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + } else { + userSuggestions = emptyList() + } + } + } + + open fun updateZapForwardTo(it: TextFieldValue) { + forwardZapToEditting = it + if (it.selection.collapsed) { + val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = false if (lastWord.startsWith("@") && lastWord.length > 2) { userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) } else { @@ -186,15 +229,29 @@ open class NewPostViewModel : ViewModel() { open fun autocompleteWithUser(item: User) { userSuggestionAnchor?.let { - val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") - val lastWordStart = it.end - lastWord.length - val wordToInsert = "@${item.pubkeyNpub()}" + if (userSuggestionsMainMessage == true) { + val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + + message = TextFieldValue( + message.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + ) + } else { + val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + forwardZapTo = item + + forwardZapToEditting = TextFieldValue( + forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + ) + } - message = TextFieldValue( - message.text.replaceRange(lastWordStart, it.end, wordToInsert), - TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) - ) userSuggestionAnchor = null + userSuggestionsMainMessage = null userSuggestions = emptyList() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fb553efd..3db79ad44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -325,5 +325,5 @@ File Server - + LnAddress or @User From 285a61e38fba83545b2f6742098bed6f1b7e133b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 15:01:58 -0400 Subject: [PATCH 087/164] Enabling non NIP94 uploads for the PlayStore --- .../amethyst/ui/actions/ImageUploader.kt | 3 +- .../amethyst/ui/actions/NewPostView.kt | 56 ++++++++++++------- .../amethyst/ui/actions/NewPostViewModel.kt | 8 ++- 3 files changed, 44 insertions(+), 23 deletions(-) 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 6238ddf9a..7dd5bb23f 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,7 +11,6 @@ import okio.BufferedSink import okio.source import java.io.IOException import java.io.InputStream -import java.util.* val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') @@ -35,7 +34,7 @@ object ImageUploader { val myServer = if (server == ServersAvailable.IMGUR) { ImgurServer() - } else if (server == ServersAvailable.NOSTR_IMG) { + } else if (server == ServersAvailable.NOSTRIMG) { NostrImgServer() } else { ImgurServer() 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 17d67d7ec..ae9fc3177 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 @@ -564,7 +564,12 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } enum class ServersAvailable { - IMGUR, NOSTR_BUILD, NOSTR_IMG, NIP95 + IMGUR, + NOSTR_BUILD, + NOSTRIMG, + IMGUR_NIP_94, + NOSTRIMG_NIP_94, + NIP95 } @Composable @@ -582,12 +587,15 @@ fun ImageVideoDescription( val isVideo = mediaType.startsWith("video") val fileServers = listOf( - Pair(ServersAvailable.IMGUR, "imgur.com"), - Pair(ServersAvailable.NOSTR_IMG, "nostrimg.com"), - Pair(ServersAvailable.NIP95, "your relays (NIP-95)") + Triple(ServersAvailable.IMGUR, "imgur.com", "Uploads to ImgUR. ImgUR can change your image at any time"), + Triple(ServersAvailable.NOSTRIMG, "nostrimg.com", "Regular NostrImg. NostrImg can change your image at any time"), + Triple(ServersAvailable.IMGUR_NIP_94, "Verifiable ImgUr (NIP-94)", "Protects from ImgUr changing your image after you post"), + Triple(ServersAvailable.NOSTRIMG_NIP_94, "Verifiable NostrIMG (NIP-94)", "Protects from NostrIMG changing your image after you post"), + Triple(ServersAvailable.NIP95, "Your relays (NIP-95)", "The image is hosted in the relay itself. Your image will be free from a domain name / third-party control") ) val fileServerOptions = fileServers.map { it.second } + val fileServerExplainers = fileServers.map { it.third } var selectedServer by remember { mutableStateOf(defaultServer) } var message by remember { mutableStateOf("") } @@ -697,6 +705,7 @@ fun ImageVideoDescription( label = stringResource(id = R.string.file_server), placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second, options = fileServerOptions, + explainers = fileServerExplainers, onSelect = { selectedServer = fileServers[it].first }, @@ -705,25 +714,32 @@ fun ImageVideoDescription( ) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || + selectedServer == ServersAvailable.IMGUR_NIP_94 || + selectedServer == ServersAvailable.NIP95 ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier.fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences ) - ) + } } Button( 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 267f8f576..22c7994f4 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 @@ -147,7 +147,13 @@ open class NewPostViewModel : ViewModel() { server = server, contentResolver = contentResolver, onSuccess = { imageUrl, mimeType -> - createNIP94Record(imageUrl, mimeType, description) + if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94) { + createNIP94Record(imageUrl, mimeType, description) + } else { + isUploadingImage = false + message = TextFieldValue(message.text + "\n\n" + imageUrl) + urlPreview = findUrlInMessage() + } }, onError = { isUploadingImage = false From 561189144eb818c2f2beef56adeaa44606320629 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 15:06:04 -0400 Subject: [PATCH 088/164] v0.37.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 27f9c25bb..846c6f530 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 127 - versionName "0.37.0" + versionCode 128 + versionName "0.37.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 362082d6087fdd77bc935d94b5fd3ee964d3df2c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 15:40:28 -0400 Subject: [PATCH 089/164] Correcly copy pasting NIP94 and NIP95 addresses. --- .../ui/components/ZoomableContentView.kt | 38 +++++++++++++------ .../amethyst/ui/note/NoteCompose.kt | 10 +++-- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 55292064d..bb31eeb9e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -80,25 +80,29 @@ abstract class ZoomableContent( abstract class ZoomableUrlContent( val url: String, description: String? = null, - val hash: String? = null + val hash: String? = null, + val uri: String? = null ) : ZoomableContent(description) class ZoomableUrlImage( url: String, description: String? = null, hash: String? = null, - val bluehash: String? = null -) : ZoomableUrlContent(url, description, hash) + val bluehash: String? = null, + uri: String? = null +) : ZoomableUrlContent(url, description, hash, uri) class ZoomableUrlVideo( url: String, description: String? = null, - hash: String? = null -) : ZoomableUrlContent(url, description, hash) + hash: String? = null, + uri: String? = null +) : ZoomableUrlContent(url, description, hash, uri) abstract class ZoomablePreloadedContent( description: String? = null, - val isVerified: Boolean? = null + val isVerified: Boolean? = null, + val uri: String ) : ZoomableContent(description) class ZoomableBitmapImage( @@ -106,15 +110,17 @@ class ZoomableBitmapImage( val mimeType: String? = null, description: String? = null, val bluehash: String? = null, - isVerified: Boolean? = null -) : ZoomablePreloadedContent(description, isVerified) + isVerified: Boolean? = null, + uri: String +) : ZoomablePreloadedContent(description, isVerified, uri) class ZoomableBytesVideo( val byteArray: ByteArray, val mimeType: String? = null, description: String? = null, - isVerified: Boolean? = null -) : ZoomablePreloadedContent(description, isVerified) + isVerified: Boolean? = null, + uri: String +) : ZoomablePreloadedContent(description, isVerified, uri) fun figureOutMimeType(fullUrl: String): ZoomableContent { val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() @@ -189,7 +195,17 @@ fun ZoomableContentView(content: ZoomableContent, images: List if (content is ZoomableUrlContent) { mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) } + onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) } + ) + } else if (content is ZoomableBitmapImage) { + mainImageModifier = mainImageModifier.combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) } + ) + } else if (content is ZoomableBytesVideo) { + mainImageModifier = mainImageModifier.combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) } ) } else { mainImageModifier = mainImageModifier.clickable { 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 bdc8aba6f..0f2237fd5 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 @@ -795,10 +795,11 @@ fun FileHeaderDisplay(note: Note) { val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + val uri = "nostr:" + note.toNEvent() content = if (isImage) { - ZoomableUrlImage(fullUrl, description, hash, blurHash) + ZoomableUrlImage(fullUrl, description, hash, blurHash, uri) } else { - ZoomableUrlVideo(fullUrl, description, hash) + ZoomableUrlVideo(fullUrl, description, hash, uri) } } } @@ -823,16 +824,17 @@ fun FileStorageHeaderDisplay(baseNote: Note) { LaunchedEffect(key1 = eventHeader.id, key2 = noteState) { withContext(Dispatchers.IO) { + val uri = "nostr:" + baseNote.toNEvent() val bytes = eventBytes?.decode() val blurHash = eventHeader.blurhash() val description = eventHeader.content val mimeType = eventHeader.mimeType() content = if (mimeType?.startsWith("image") == true) { - ZoomableBitmapImage(bytes, mimeType, description, blurHash, true) + ZoomableBitmapImage(bytes, mimeType, description, blurHash, true, uri) } else { if (bytes != null) { - ZoomableBytesVideo(bytes, mimeType, description, true) + ZoomableBytesVideo(bytes, mimeType, description, true, uri) } else { null } From 2f457d135f90c1a4b4dfecb6f70e9312308cdde9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 15:54:26 -0400 Subject: [PATCH 090/164] Improving translations --- .../amethyst/ui/actions/NewPostView.kt | 62 +++++++++++++------ app/src/main/res/values/strings.xml | 16 +++++ 2 files changed, 60 insertions(+), 18 deletions(-) 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 ae9fc3177..9fea53e30 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 @@ -307,7 +307,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } - Row(modifier = Modifier.fillMaxWidth().height(50.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + verticalAlignment = Alignment.CenterVertically + ) { UploadFromGallery( isUploading = postViewModel.isUploadingImage, tint = MaterialTheme.colors.onBackground, @@ -406,31 +411,43 @@ private fun ForwardZapTo( onClick() } ) { - Box(Modifier.height(20.dp).width(25.dp)) { + Box( + Modifier + .height(20.dp) + .width(25.dp) + ) { if (!postViewModel.wantsForwardZapTo) { Icon( imageVector = Icons.Default.Bolt, contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + modifier = Modifier + .size(20.dp) + .align(Alignment.CenterStart), tint = MaterialTheme.colors.onBackground ) Icon( imageVector = Icons.Default.ArrowForwardIos, contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + modifier = Modifier + .size(13.dp) + .align(Alignment.CenterEnd), tint = MaterialTheme.colors.onBackground ) } else { Icon( imageVector = Icons.Outlined.Bolt, contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + modifier = Modifier + .size(20.dp) + .align(Alignment.CenterStart), tint = BitcoinOrange ) Icon( imageVector = Icons.Outlined.ArrowForwardIos, contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + modifier = Modifier + .size(13.dp) + .align(Alignment.CenterEnd), tint = BitcoinOrange ) } @@ -443,7 +460,10 @@ private fun ForwardZapTo( onValueChange = { postViewModel.updateZapForwardTo(it) }, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).padding(0.dp), + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .padding(0.dp), placeholder = { Text( text = stringResource(R.string.zap_forward_lnAddress), @@ -587,11 +607,11 @@ fun ImageVideoDescription( val isVideo = mediaType.startsWith("video") val fileServers = listOf( - Triple(ServersAvailable.IMGUR, "imgur.com", "Uploads to ImgUR. ImgUR can change your image at any time"), - Triple(ServersAvailable.NOSTRIMG, "nostrimg.com", "Regular NostrImg. NostrImg can change your image at any time"), - Triple(ServersAvailable.IMGUR_NIP_94, "Verifiable ImgUr (NIP-94)", "Protects from ImgUr changing your image after you post"), - Triple(ServersAvailable.NOSTRIMG_NIP_94, "Verifiable NostrIMG (NIP-94)", "Protects from NostrIMG changing your image after you post"), - Triple(ServersAvailable.NIP95, "Your relays (NIP-95)", "The image is hosted in the relay itself. Your image will be free from a domain name / third-party control") + Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)), + Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)), + Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), + Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), + Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) ) val fileServerOptions = fileServers.map { it.second } @@ -638,7 +658,8 @@ fun ImageVideoDescription( fontWeight = FontWeight.W500, modifier = Modifier .padding(start = 10.dp) - .weight(1.0f).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .weight(1.0f) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) IconButton( @@ -662,7 +683,8 @@ fun ImageVideoDescription( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .padding(bottom = 10.dp) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { if (mediaType.startsWith("image")) { AsyncImage( @@ -671,7 +693,8 @@ fun ImageVideoDescription( contentScale = ContentScale.FillWidth, modifier = Modifier .padding(top = 4.dp) - .fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { var bitmap by remember { mutableStateOf(null) } @@ -709,7 +732,8 @@ fun ImageVideoDescription( onSelect = { selectedServer = fileServers[it].first }, - modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + modifier = Modifier + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) .weight(1f) ) } @@ -720,12 +744,14 @@ fun ImageVideoDescription( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { OutlinedTextField( label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), value = message, onValueChange = { message = it }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3db79ad44..921787a85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -326,4 +326,20 @@ File Server LnAddress or @User + + imgur.com - less secure + Uploads to Imgur. Imgur can change your image/video at any time + + nostrimg.com - less secure + Uploads to NostrImg. NostrImg can change your image at any time + + Verifiable Imgur (NIP-94) + Protects from Imgur changing your image/video after you post + + Verifiable NostrImg (NIP-94) + Protects from NostrImg changing your image after you post + + Your relays (NIP-95) + Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list + From 54511dcc6964e18d8ecde59ebc2116fd0272993b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 16:23:30 -0400 Subject: [PATCH 091/164] Updating dependencies --- app/build.gradle | 8 ++++---- build.gradle | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 846c6f530..46d2a3829 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,8 +85,8 @@ android { } dependencies { - implementation 'androidx.core:core-ktx:1.10.0' - implementation 'androidx.activity:activity-compose:1.7.0-beta01' + implementation 'androidx.core:core-ktx:1.11.0-alpha03' + implementation 'androidx.activity:activity-compose:1.7.0' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" @@ -134,7 +134,7 @@ dependencies { implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1' // Encrypted Key Storage - implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05' + implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06' // view videos implementation 'com.google.android.exoplayer:exoplayer:2.18.6' @@ -177,7 +177,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'io.mockk:mockk:1.13.5' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" diff --git a/build.gradle b/build.gradle index 0c3fda9d7..e93e47bce 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ buildscript { ext { - fragment_version = "1.5.6" + fragment_version = "1.5.7" lifecycle_version = '2.6.1' compose_ui_version = '1.5.0-alpha03' nav_version = "2.5.3" From f6f82a16f5d6039ab5282048796b8585e75f1f8c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 16:23:38 -0400 Subject: [PATCH 092/164] v0.37.2 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 46d2a3829..2e268a88d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 128 - versionName "0.37.1" + versionCode 129 + versionName "0.37.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 925289ae64b7921b07dca97e682f65170195c63d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:13:45 -0400 Subject: [PATCH 093/164] Reducing the amount of filters to send to relays to 10 --- .../java/com/vitorpamplona/amethyst/service/relays/Relay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 60b7cb31b..280e7bbe7 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 @@ -205,7 +205,7 @@ class Relay( val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() } if (filters.isNotEmpty()) { val request = - """["REQ","$requestId",${filters.take(12).joinToString(",") { it.filter.toJson(url) }}]""" + """["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]""" // println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() From 0fe6bd6c8372f58bebf238ec207479d5bb051e7b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:13:56 -0400 Subject: [PATCH 094/164] Bugfix for clickable url --- .../com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 1d84fa2e8..d6a082a08 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 @@ -11,9 +11,11 @@ import androidx.compose.ui.text.AnnotatedString fun ClickableUrl(urlText: String, url: String) { val uri = LocalUriHandler.current + val doubleCheckedUrl = if (url.contains("://")) url else "https://$url" + ClickableText( text = AnnotatedString(urlText), - onClick = { runCatching { uri.openUri(url) } }, + onClick = { runCatching { uri.openUri(doubleCheckedUrl) } }, style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) ) } From ddba4ac5100691244f96aaac7798061580a87f55 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:35:36 -0400 Subject: [PATCH 095/164] Add logs for fail to decrypt zap --- .../main/java/com/vitorpamplona/amethyst/model/Account.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 2a1cfc26f..2a0a11662 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.model import android.content.res.Resources +import android.util.Log import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.FileHeader @@ -779,7 +780,11 @@ class Account( } if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse) + val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse) + if (result == null) { + Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}") + } + result } else { null } From 1ae785061e81ec9c84c754e6d3af40a26c518fde Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:35:53 -0400 Subject: [PATCH 096/164] Reverting compose to 1.4.2 --- app/build.gradle | 2 +- .../vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt | 2 +- build.gradle | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2e268a88d..ebc2cd388 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,7 +85,7 @@ android { } dependencies { - implementation 'androidx.core:core-ktx:1.11.0-alpha03' + implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.activity:activity-compose:1.7.0' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" 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 5b6049107..26a563099 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 @@ -496,7 +496,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode userBadge.acceptedBadges?.let { note -> (note.event as? BadgeProfilesEvent)?.let { event -> - FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) { + FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { event.badgeAwardEvents().forEach { badgeAwardEvent -> val baseNote = LocalCache.notes[badgeAwardEvent] if (baseNote != null) { diff --git a/build.gradle b/build.gradle index e93e47bce..503c6fd89 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { fragment_version = "1.5.7" lifecycle_version = '2.6.1' - compose_ui_version = '1.5.0-alpha03' + compose_ui_version = '1.4.2' nav_version = "2.5.3" room_version = "2.4.3" accompanist_version = '0.30.0' From 6b3e10ed2844d75ec3faec793f1ad292de122685 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:37:23 -0400 Subject: [PATCH 097/164] Version 0.37.3 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ebc2cd388..0e19a3b58 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 129 - versionName "0.37.2" + versionCode 130 + versionName "0.37.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 8ca63f32f45e6b227b2a993eb92fe1115251acb6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 18:20:04 -0400 Subject: [PATCH 098/164] Avoids flickering in the Global Feed --- .../java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f17d5ade5..aa37f11ab 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 @@ -45,6 +45,6 @@ object GlobalFeedFilter : AdditiveFeedFilter() { } override fun sort(collection: Set): List { - return collection.sortedBy { it.createdAt() }.reversed() + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() } } From f173f3194bedd4e6ab418f0acb04b057553d9dc9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 18:27:56 -0400 Subject: [PATCH 099/164] Adjusts size of the Zap Forward text. --- .../java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 9fea53e30..e6fe1e9a4 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 @@ -467,7 +467,8 @@ private fun ForwardZapTo( placeholder = { Text( text = stringResource(R.string.zap_forward_lnAddress), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontSize = 14.sp ) }, colors = TextFieldDefaults From d1dc06a467d3f5e5e1849585a9fd50380ed99d33 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 18:29:04 -0400 Subject: [PATCH 100/164] v0.37.4 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0e19a3b58..394c1b6ec 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 130 - versionName "0.37.3" + versionCode 131 + versionName "0.37.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 2a552bea4d5c04f0c35f69c1d4e0dbb1ef852081 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 19:30:20 -0400 Subject: [PATCH 101/164] Update PRIVACY.md Clarifying that the PRIVACY policy covers the distribution usage and not the source code usage. --- PRIVACY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 5baf61621..7c481a0c4 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,12 +1,12 @@ # Amethyst Privacy Policy & Terms of Use -Effective as of Jan 20, 2023. +Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them. Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app. -You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling. +You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling. We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy. From 1496f012a525791e30896b4f177beee674667140 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 28 Apr 2023 18:40:12 -0400 Subject: [PATCH 102/164] Adds new Highlight event kind from https://highlighter.com/ --- .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../amethyst/model/LocalCache.kt | 46 +++++++++-- .../amethyst/service/NostrDataSource.kt | 7 +- .../amethyst/service/NostrGlobalDataSource.kt | 3 +- .../amethyst/service/NostrHomeDataSource.kt | 5 +- .../NostrSearchEventOrUserDataSource.kt | 2 +- .../service/NostrSingleEventDataSource.kt | 6 +- .../service/NostrUserProfileDataSource.kt | 2 +- .../amethyst/service/model/Event.kt | 3 + .../amethyst/service/model/HighlightEvent.kt | 36 +++++++++ .../amethyst/ui/components/RichTextViewer.kt | 3 +- .../ui/dal/HomeNewThreadFeedFilter.kt | 3 +- .../amethyst/ui/note/NoteCompose.kt | 81 +++++++++++++++++++ .../amethyst/ui/screen/ThreadFeedView.kt | 50 +++++++----- 14 files changed, 213 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt 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 2a0a11662..d80373eaf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -396,10 +396,10 @@ class Account( ) Client.send(data) - LocalCache.consume(data) + LocalCache.consume(data, null) Client.send(signedEvent) - LocalCache.consume(signedEvent) + LocalCache.consume(signedEvent, null) return LocalCache.notes[signedEvent.id] } @@ -418,7 +418,7 @@ class Account( ) Client.send(signedEvent) - LocalCache.consume(signedEvent) + LocalCache.consume(signedEvent, null) return LocalCache.notes[signedEvent.id] } 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 d2e76ffab..a368fa75b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -653,40 +653,72 @@ object LocalCache { refreshObservers(note) } - fun consume(event: FileHeaderEvent) { + fun consume(event: FileHeaderEvent, relay: Relay?) { val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // Already processed this event. if (note.event != null) return - val author = getOrCreateUser(event.pubKey) - note.loadEvent(event, author, emptyList()) refreshObservers(note) } - fun consume(event: FileStorageHeaderEvent) { + fun consume(event: FileStorageHeaderEvent, relay: Relay?) { val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // Already processed this event. if (note.event != null) return - val author = getOrCreateUser(event.pubKey) - note.loadEvent(event, author, emptyList()) refreshObservers(note) } - fun consume(event: FileStorageEvent) { + fun consume(event: HighlightEvent, relay: Relay?) { val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // Already processed this event. if (note.event != null) return + note.loadEvent(event, author, emptyList()) + + // Adds to user profile + author.addNote(note) + + refreshObservers(note) + } + + fun consume(event: FileStorageEvent, relay: Relay?) { + val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + note.loadEvent(event, author, emptyList()) refreshObservers(note) 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 b5789a178..8335a7177 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -76,9 +76,10 @@ abstract class NostrDataSource(val debugName: String) { is ContactListEvent -> LocalCache.consume(event) is DeletionEvent -> LocalCache.consume(event) - is FileHeaderEvent -> LocalCache.consume(event) - is FileStorageEvent -> LocalCache.consume(event) - is FileStorageHeaderEvent -> LocalCache.consume(event) + is FileHeaderEvent -> LocalCache.consume(event, relay) + is FileStorageEvent -> LocalCache.consume(event, relay) + is FileStorageHeaderEvent -> LocalCache.consume(event, relay) + is HighlightEvent -> LocalCache.consume(event, relay) is LnZapEvent -> { event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) 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 dbcb987e6..ebf3425db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent @@ -12,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") { fun createGlobalFilter() = TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), limit = 200 ) ) 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 b07eab060..5f855bc7c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent @@ -55,7 +56,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), authors = followSet, limit = 400, since = latestEOSEs.users[account.userProfile()]?.relayList @@ -71,7 +72,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), tags = mapOf( "t" to hashToLoad.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) 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 29ba72306..a7262a2f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -60,7 +60,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind), search = mySearchString, limit = 20 ) 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 85746e500..2e3123c31 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -79,7 +79,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, - PollNoteEvent.kind + PollNoteEvent.kind, + HighlightEvent.kind ), tags = mapOf("e" to listOf(it.idHex)), since = it.lastReactionsDownloadTime @@ -117,7 +118,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, PrivateDmEvent.kind, - FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind + FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind, + HighlightEvent.kind ), ids = interestedEvents.toList() ) 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 19671718a..23e2196e4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -35,7 +35,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), authors = listOf(it.pubkeyHex), limit = 200 ) 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 33c09bbe5..508f36487 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 @@ -41,6 +41,8 @@ open class Event( fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } + override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1) fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { @@ -227,6 +229,7 @@ open class Event( FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) + HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt new file mode 100644 index 000000000..1d3d1acf6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt @@ -0,0 +1,36 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class HighlightEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { + + fun inUrl() = taggedUrls().firstOrNull() + fun author() = taggedUsers().firstOrNull() + fun quote() = content + + companion object { + const val kind = 9802 + + fun create( + msg: String, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): PollNoteEvent { + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = mutableListOf>() + val id = generateId(pubKey, createdAt, kind, tags, msg) + val sig = Utils.sign(id, privateKey) + return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) + } + } +} 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 25e05d8eb..2cdac42f4 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 @@ -86,7 +86,8 @@ fun RichTextViewer( navController: NavController ) { Column(modifier = modifier) { - if (content.startsWith("# ") || + if (content.startsWith("> ") || + content.startsWith("# ") || content.contains("##") || content.contains("**") || content.contains("__") || 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 d31a45c68..b5f62086e 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 @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.RepostEvent @@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { return collection .asSequence() .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) && + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true && 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 0f2237fd5..6732bd98f 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 @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.theme.Following import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.math.BigDecimal +import java.net.URL import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -517,6 +518,26 @@ fun NoteComposeInner( ReactionsRow(note, accountViewModel, navController) } + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } else if (noteEvent is HighlightEvent) { + DisplayHighlight( + noteEvent.quote(), + noteEvent.author(), + noteEvent.inUrl(), + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + + if (!makeItShort) { + ReactionsRow(note, accountViewModel, navController) + } + Divider( modifier = Modifier.padding(top = 10.dp), thickness = 0.25.dp @@ -574,6 +595,66 @@ fun NoteComposeInner( } } +@Composable +fun DisplayHighlight( + highlight: String, + authorHex: String?, + url: String?, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val quote = highlight.split("\n").map { "> *${it.removeSuffix(" ")}*" }.joinToString("\n") + + if (quote != null) { + TranslatableRichTextViewer( + quote, + canPreview = canPreview && !makeItShort, + Modifier.fillMaxWidth(), + emptyList(), + backgroundColor, + accountViewModel, + navController + ) + } + + FlowRow() { + authorHex?.let { authorHex -> + val userBase = LocalCache.checkGetOrCreateUser(authorHex) + + if (userBase != null) { + val userState by userBase.live().metadata.observeAsState() + val user = userState?.user + + if (user != null) { + CreateClickableText( + user.toBestDisplayName(), + "", + "User/${user.pubkeyHex}", + navController + ) + } + } + } + + url?.let { url -> + val validatedUrl = try { + URL(url) + } catch (e: Exception) { + Log.w("Note Compose", "Invalid URI: $url") + null + } + + validatedUrl?.host?.let { host -> + Text("on ") + ClickableUrl(urlText = host, url = url) + } + } + } +} + @Composable fun DisplayFollowingHashtagsInPost( noteEvent: EventInterface, 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 2b3a6b466..045ce13d8 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 @@ -54,6 +54,7 @@ import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status @@ -348,33 +349,46 @@ fun NoteMaster( ) ) { Column() { - val eventContent = note.event?.content() - - val canPreview = note.author == account.userProfile() || - (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - if (eventContent != null) { - TranslatableRichTextViewer( - eventContent, - canPreview, - Modifier.fillMaxWidth(), - note.event?.tags(), - MaterialTheme.colors.background, + if (noteEvent is HighlightEvent) { + DisplayHighlight( + noteEvent.quote(), + noteEvent.author(), + noteEvent.inUrl(), + false, + true, + backgroundColor, accountViewModel, navController ) + } else { + val eventContent = note.event?.content() - DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + val canPreview = note.author == account.userProfile() || + (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || + !noteForReports.hasAnyReports() - if (noteEvent is PollNoteEvent) { - PollNote( - note, + if (eventContent != null) { + TranslatableRichTextViewer( + eventContent, canPreview, - backgroundColor, + Modifier.fillMaxWidth(), + note.event?.tags(), + MaterialTheme.colors.background, accountViewModel, navController ) + + DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + + if (noteEvent is PollNoteEvent) { + PollNote( + note, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + } } } From 571ff0e72badda4a838c5177bf84947916bfb3f4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 28 Apr 2023 18:42:44 -0400 Subject: [PATCH 103/164] v0.38.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 394c1b6ec..3b8cf318c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 131 - versionName "0.37.4" + versionCode 132 + versionName "0.38.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 206935289ed25b06cde442c3df4fc13cff1f9ef0 Mon Sep 17 00:00:00 2001 From: Zoltan <34719275+ZsZolee@users.noreply.github.com> Date: Sat, 29 Apr 2023 10:45:16 +0200 Subject: [PATCH 104/164] Upload server Hungarian translation --- app/src/main/res/values-hu/strings.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ab2e4a80a..c47fda57e 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -315,4 +315,23 @@ Nem Zap Nostr-ban nyoma sincs, csak a Lightning-ben + + Fájl Szerver + LnCím vagy @Felhasználó + + imgur.com - kevésbé biztonságos + Feltöltés az Imgur-ba. Az Imgur a képet/videót bármikor megváltoztathatja + + nostrimg.com - kevésbé biztonságos + Feltöltés a NostrImg-be. A NostrImg a képet bármikor megváltoztathatja + + Ellenőrizhető Imgur (NIP-94) + Megvéd attól, hogy az Imgur a képet/videót a közzététel után megváltoztassa + + Ellenőrizhető NostrImg (NIP-94) + Megvéd attól, hogy az NostrImg a képet a közzététel után megváltoztassa + + Saját csomópontjaid (NIP-95) + A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel + From 8209c453030ec5e984a390358ac66cb57256703f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:28:46 -0400 Subject: [PATCH 105/164] New Video Feed based on NIP-94 and 95 --- .../amethyst/service/NostrVideoDataSource.kt | 23 ++ .../amethyst/ui/actions/NewMediaModel.kt | 145 +++++++ .../amethyst/ui/actions/NewMediaView.kt | 226 +++++++++++ .../ui/components/ZoomableContentView.kt | 2 +- .../amethyst/ui/dal/VideoFeedFilter.kt | 40 ++ .../amethyst/ui/navigation/AppBottomBar.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 24 ++ .../amethyst/ui/navigation/Routes.kt | 6 + .../amethyst/ui/note/ReactionsRow.kt | 50 +-- .../amethyst/ui/screen/FeedViewModel.kt | 2 + .../amethyst/ui/screen/loggedIn/MainScreen.kt | 38 +- .../ui/screen/loggedIn/VideoScreen.kt | 356 ++++++++++++++++++ app/src/main/res/drawable/ic_video.xml | 9 + 13 files changed, 875 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt create mode 100644 app/src/main/res/drawable/ic_video.xml diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt new file mode 100644 index 000000000..f35fe3cf6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -0,0 +1,23 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.service.model.FileHeaderEvent +import com.vitorpamplona.amethyst.service.model.FileStorageEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter + +object NostrVideoDataSource : NostrDataSource("VideoFeed") { + fun createGlobalFilter() = TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(FileHeaderEvent.kind, FileStorageEvent.kind), + limit = 200 + ) + ) + + val videoFeedChannel = requestNewChannel() + + override fun updateChannelFilters() { + videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt new file mode 100644 index 000000000..2c4a89999 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -0,0 +1,145 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.* +import com.vitorpamplona.amethyst.service.FileHeader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +open class NewMediaModel : ViewModel() { + var account: Account? = null + + var isUploadingImage by mutableStateOf(false) + val imageUploadingError = MutableSharedFlow() + var mediaType by mutableStateOf(null) + + var selectedServer by mutableStateOf(null) + var description by mutableStateOf("") + + // Images and Videos + var galleryUri by mutableStateOf(null) + + open fun load(account: Account, uri: Uri, contentType: String?) { + this.account = account + this.galleryUri = uri + this.mediaType = contentType + this.selectedServer = defaultServer() + + if (selectedServer == ServersAvailable.IMGUR) { + selectedServer = ServersAvailable.IMGUR_NIP_94 + } else if (selectedServer == ServersAvailable.NOSTRIMG) { + selectedServer = ServersAvailable.NOSTRIMG_NIP_94 + } + } + + fun upload(context: Context, onClose: () -> Unit) { + isUploadingImage = true + + val contentResolver = context.contentResolver + val uri = galleryUri ?: return + val serverToUse = selectedServer ?: return + + if (selectedServer == ServersAvailable.NIP95) { + val contentType = contentResolver.getType(uri) + contentResolver.openInputStream(uri)?.use { + createNIP95Record(it.readBytes(), contentType, description, onClose) + } + ?: viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } else { + ImageUploader.uploadImage( + uri = uri, + server = serverToUse, + contentResolver = contentResolver, + onSuccess = { imageUrl, mimeType -> + createNIP94Record(imageUrl, mimeType, description, onClose) + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + + open fun cancel() { + galleryUri = null + isUploadingImage = false + mediaType = null + + description = "" + selectedServer = account?.defaultFileServer + } + + fun canPost(): Boolean { + return !isUploadingImage && galleryUri != null && selectedServer != null + } + + fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + // Images don't seem to be ready immediately after upload + + if (mimeType?.startsWith("image/") == true) { + delay(2000) + } else { + delay(5000) + } + + FileHeader.prepare( + imageUrl, + mimeType, + description, + onReady = { + val note = account?.sendHeader(it) + isUploadingImage = false + cancel() + onClose() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + + fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + "", + mimeType, + description, + onReady = { + account?.sendNip95(bytes, headerInfo = it) + isUploadingImage = false + cancel() + onClose() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + + fun isImage() = mediaType?.startsWith("image") + fun isVideo() = mediaType?.startsWith("video") + fun defaultServer() = account?.defaultFileServer +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt new file mode 100644 index 000000000..b50b1843e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -0,0 +1,226 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.util.Size +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.* +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) { + val account = accountViewModel.accountLiveData.value?.account ?: return + val resolver = LocalContext.current.contentResolver + val postViewModel: NewMediaModel = viewModel() + val context = LocalContext.current + + val scroolState = rememberScrollState() + + LaunchedEffect(Unit) { + val mediaType = resolver.getType(uri) ?: "" + postViewModel.load(account, uri, mediaType) + delay(100) + + postViewModel.imageUploadingError.collect { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp) + .fillMaxWidth() + .fillMaxHeight().imePadding() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + postViewModel.cancel() + onClose() + }) + + if (postViewModel.isUploadingImage) { + LoadingAnimation() + } + + PostButton( + onPost = { + postViewModel.upload(context) { + onClose() + } + }, + isActive = postViewModel.canPost() + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scroolState) + ) { + ImageVideoPost(postViewModel) + } + } + } + } + } +} + +@Composable +fun ImageVideoPost(postViewModel: NewMediaModel) { + val scope = rememberCoroutineScope() + + val fileServers = listOf( + Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), + Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), + Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) + ) + + val fileServerOptions = fileServers.map { it.second } + val fileServerExplainers = fileServers.map { it.third } + val resolver = LocalContext.current.contentResolver + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) { + if (postViewModel.isImage() == true) { + AsyncImage( + model = postViewModel.galleryUri.toString(), + contentDescription = postViewModel.galleryUri.toString(), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) + } else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var bitmap by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = postViewModel.galleryUri) { + scope.launch(Dispatchers.IO) { + postViewModel.galleryUri?.let { + bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) + } + } + } + + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + ) + } + } else { + postViewModel.galleryUri?.let { + VideoView(it) + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + TextSpinner( + label = stringResource(id = R.string.file_server), + placeholder = fileServers.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.second ?: fileServers[0].second, + options = fileServerOptions, + explainers = fileServerExplainers, + onSelect = { + postViewModel.selectedServer = fileServers[it].first + }, + modifier = Modifier + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .weight(1f) + ) + } + + if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || + postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 || + postViewModel.selectedServer == ServersAvailable.NIP95 + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = postViewModel.description, + onValueChange = { postViewModel.description = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ) + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index bb31eeb9e..775851211 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -406,7 +406,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List() { + lateinit var account: Account + + override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values) + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + val now = System.currentTimeMillis() / 1000 + + return collection + .asSequence() + .filter { + it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent + } + .filter { account.isAcceptable(it) } + .filter { + // Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating. + it.createdAt()!! <= now + } + .toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } +} 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 db0ab3644..95048de58 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 @@ -47,6 +47,7 @@ import kotlin.time.ExperimentalTime val bottomNavigationItems = listOf( Route.Home, Route.Message, + Route.Video, Route.Search, Route.Notification ) 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 fc655954e..512e6c8e2 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 @@ -17,9 +17,11 @@ import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter +import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen @@ -34,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen @OptIn(ExperimentalFoundationApi::class) @Composable @@ -58,10 +61,31 @@ fun AppNavigation( GlobalFeedFilter.account = account val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel() + VideoFeedFilter.account = account + val videoFeedViewModel: NostrVideoFeedViewModel = viewModel() + NotificationFeedFilter.account = account val notifFeedViewModel: NotificationViewModel = viewModel() NavHost(navController, startDestination = Route.Home.route) { + Route.Video.let { route -> + composable(route.route, route.arguments, content = { + val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false + + VideoScreen( + videoFeedView = videoFeedViewModel, + accountViewModel = accountViewModel, + navController = navController, + scrollToTop = scrollToTop + ) + + // Avoids running scroll to top when back button is pressed + if (scrollToTop) { + it.arguments?.remove("scrollToTop") + } + }) + } + Route.Search.let { route -> composable(route.route, route.arguments, content = { val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false 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 f0266f41a..a123dc0c4 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 @@ -41,6 +41,12 @@ sealed class Route( arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }) ) + object Video : Route( + route = "Video?scrollToTop={scrollToTop}", + icon = R.drawable.ic_video, + arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }) + ) + object Notification : Route( route = "Notification?scrollToTop={scrollToTop}", icon = R.drawable.ic_notifications, 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 4b704985d..27c377036 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 @@ -45,6 +45,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -116,6 +117,7 @@ fun ReplyReaction( baseNote: Note, accountViewModel: AccountViewModel, showCounter: Boolean = true, + iconSize: Dp = 20.dp, onPress: () -> Unit ) { val repliesState by baseNote.live().replies.observeAsState() @@ -125,7 +127,7 @@ fun ReplyReaction( val scope = rememberCoroutineScope() IconButton( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), onClick = { if (accountViewModel.isWriteable()) { onPress() @@ -140,7 +142,7 @@ fun ReplyReaction( } } ) { - ReplyIcon() + ReplyIcon(iconSize) } if (showCounter) { @@ -153,19 +155,20 @@ fun ReplyReaction( } @Composable -private fun ReplyIcon() { +private fun ReplyIcon(iconSize: Dp = 15.dp) { Icon( painter = painterResource(R.drawable.ic_comment), null, - modifier = Modifier.size(15.dp), + modifier = Modifier.size(iconSize), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @Composable -private fun BoostReaction( +public fun BoostReaction( baseNote: Note, accountViewModel: AccountViewModel, + iconSize: Dp = 20.dp, onQuotePress: () -> Unit ) { val boostsState by baseNote.live().boosts.observeAsState() @@ -178,7 +181,7 @@ private fun BoostReaction( var wantsToBoost by remember { mutableStateOf(false) } IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), + modifier = Modifier.then(Modifier.size(iconSize)), onClick = { if (accountViewModel.isWriteable()) { if (accountViewModel.hasBoosted(baseNote)) { @@ -215,14 +218,14 @@ private fun BoostReaction( Icon( painter = painterResource(R.drawable.ic_retweeted), null, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = Color.Unspecified ) } else { Icon( painter = painterResource(R.drawable.ic_retweet), null, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = grayTint ) } @@ -238,7 +241,9 @@ private fun BoostReaction( @Composable fun LikeReaction( baseNote: Note, - accountViewModel: AccountViewModel + accountViewModel: AccountViewModel, + iconSize: Dp = 20.dp, + heartSize: Dp = 16.dp ) { val reactionsState by baseNote.live().reactions.observeAsState() val reactedNote = reactionsState?.note ?: return @@ -248,7 +253,7 @@ fun LikeReaction( val scope = rememberCoroutineScope() IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), + modifier = Modifier.then(Modifier.size(iconSize)), onClick = { if (accountViewModel.isWriteable()) { if (accountViewModel.hasReactedTo(baseNote)) { @@ -271,14 +276,14 @@ fun LikeReaction( Icon( painter = painterResource(R.drawable.ic_liked), null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(heartSize), tint = Color.Unspecified ) } else { Icon( painter = painterResource(R.drawable.ic_like), null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(heartSize), tint = grayTint ) } @@ -296,7 +301,9 @@ fun LikeReaction( fun ZapReaction( baseNote: Note, accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier + textModifier: Modifier = Modifier, + iconSize: Dp = 20.dp, + animationSize: Dp = 14.dp ) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -326,8 +333,7 @@ fun ZapReaction( Row( verticalAlignment = CenterVertically, - modifier = Modifier - .then(Modifier.size(20.dp)) + modifier = Modifier.size(iconSize) .combinedClickable( role = Role.Button, interactionSource = remember { MutableInteractionSource() }, @@ -427,7 +433,7 @@ fun ZapReaction( Icon( imageVector = Icons.Default.Bolt, contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = BitcoinOrange ) } else { @@ -435,14 +441,14 @@ fun ZapReaction( Icon( imageVector = Icons.Outlined.Bolt, contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = grayTint ) } else { Spacer(Modifier.width(3.dp)) CircularProgressIndicator( progress = zappingProgress, - modifier = Modifier.size(14.dp), + modifier = Modifier.size(animationSize), strokeWidth = 2.dp ) } @@ -466,18 +472,18 @@ fun ZapReaction( } @Composable -private fun ViewCountReaction(idHex: String) { +public fun ViewCountReaction(idHex: String, iconSize: Dp = 20.dp, barChartSize: Dp = 19.dp, numberSize: Dp = 24.dp) { val uri = LocalUriHandler.current val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) IconButton( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") } ) { Icon( imageVector = Icons.Outlined.BarChart, null, - modifier = Modifier.size(19.dp), + modifier = Modifier.size(barChartSize), tint = grayTint ) } @@ -490,7 +496,7 @@ private fun ViewCountReaction(idHex: String) { .memoryCachePolicy(CachePolicy.ENABLED) .build(), contentDescription = stringResource(R.string.view_count), - modifier = Modifier.height(24.dp), + modifier = Modifier.height(numberSize), colorFilter = ColorFilter.tint(grayTint) ) } 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 60daf1072..1290d4ed4 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 @@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter +import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -34,6 +35,7 @@ import kotlinx.coroutines.launch class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter) class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter) class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter) +class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter) class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter) class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter) class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter) 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 0adfec2b0..937332cdc 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 @@ -88,34 +88,24 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun fun FloatingButtons(navController: NavHostController, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) { val accountState by accountStateViewModel.accountContent.collectAsState() - if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { - Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is AccountState.LoggedInViewOnly -> { - // Does nothing. - } - is AccountState.LoggedOff -> { - // Does nothing. - } - is AccountState.LoggedIn -> { + Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is AccountState.LoggedInViewOnly -> { + // Does nothing. + } + is AccountState.LoggedOff -> { + // Does nothing. + } + is AccountState.LoggedIn -> { + if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { NewNoteButton(state.account, accountViewModel, navController) } - } - } - } - - if (currentRoute(navController) == Route.Message.base) { - Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is AccountState.LoggedInViewOnly -> { - // Does nothing. - } - is AccountState.LoggedOff -> { - // Does nothing. - } - is AccountState.LoggedIn -> { + if (currentRoute(navController) == Route.Message.base) { NewChannelButton(state.account) } + if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) { + NewImageButton(accountViewModel, navController) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt new file mode 100644 index 000000000..70106f2f0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -0,0 +1,356 @@ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import android.Manifest +import android.net.Uri +import android.os.Build +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.VerticalPager +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.NostrVideoDataSource +import com.vitorpamplona.amethyst.service.model.FileHeaderEvent +import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent +import com.vitorpamplona.amethyst.ui.actions.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.NewMediaView +import com.vitorpamplona.amethyst.ui.actions.NewPostView +import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status +import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter +import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ViewCountReaction +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.screen.FeedEmpty +import com.vitorpamplona.amethyst.ui.screen.FeedError +import com.vitorpamplona.amethyst.ui.screen.FeedState +import com.vitorpamplona.amethyst.ui.screen.LoadingFeed +import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel + +@Composable +fun VideoScreen( + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, + navController: NavController, + scrollToTop: Boolean = false +) { + val lifeCycleOwner = LocalLifecycleOwner.current + val account = accountViewModel.accountLiveData.value?.account ?: return + + VideoFeedFilter.account = account + + LaunchedEffect(accountViewModel) { + VideoFeedFilter.account = account + NostrVideoDataSource.resetFilters() + videoFeedView.invalidateData() + } + + DisposableEffect(accountViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Video Start") + VideoFeedFilter.account = account + NostrVideoDataSource.start() + videoFeedView.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Video Stop") + NostrVideoDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + FeedView(videoFeedView, accountViewModel, navController) + } + } +} + +@Composable +fun FeedView( + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, + navController: NavController +) { + val feedState by videoFeedView.feedContent.collectAsState() + + Box() { + Column { + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100) + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty {} + } + + is FeedState.FeedError -> { + FeedError(state.errorMessage) {} + } + + is FeedState.Loaded -> { + SlidingCarousel( + state.feed, + accountViewModel, + navController + ) + } + + is FeedState.Loading -> { + LoadingFeed() + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SlidingCarousel( + feed: MutableState>, + accountViewModel: AccountViewModel, + navController: NavController +) { + val pagerState: PagerState = remember { PagerState() } + + VerticalPager( + pageCount = feed.value.size, + state = pagerState, + beyondBoundsPageCount = 1, + modifier = Modifier.fillMaxSize(1f) + ) { index -> + feed.value.getOrNull(index)?.let { note -> + RenderVideoOrPictureNote(note, accountViewModel, navController) + } + } +} + +@Composable +private fun RenderVideoOrPictureNote( + note: Note, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event + + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + val loggedIn = account.userProfile() + + var moreActionsExpanded by remember { mutableStateOf(false) } + + Column(Modifier.fillMaxSize(1f)) { + Row(Modifier.weight(1f, true), verticalAlignment = Alignment.CenterVertically) { + if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(note) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(note) + } + } + } + + Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) { + Column(Modifier.weight(1f)) { + Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) { + Column(Modifier.size(45.dp), verticalArrangement = Arrangement.Center) { + NoteAuthorPicture(note, navController, loggedIn, 45.dp) + } + + Column( + Modifier + .padding(start = 10.dp, end = 10.dp) + .height(45.dp) + .weight(1f), + verticalArrangement = Arrangement.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + NoteUsernameDisplay(note, Modifier.weight(1f)) + + IconButton( + modifier = Modifier.size(24.dp), + onClick = { moreActionsExpanded = true } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + NoteDropDownMenu(note, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f)) + } + } + } + } + + Column( + Modifier + .width(65.dp) + .padding(bottom = 10.dp), + verticalArrangement = Arrangement.Center + ) { + Row(horizontalArrangement = Arrangement.Center) { + ReactionsColumn(note, accountViewModel, navController) + } + } + } +} + +@Composable +fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { + 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, accountViewModel, navController) + } + + if (wantsToQuote != null) { + NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) { + /* + ReplyReaction(baseNote, accountViewModel, iconSize = 40.dp) { + wantsToReplyTo = baseNote + } + BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) { + wantsToQuote = baseNote + }*/ + LikeReaction(baseNote, accountViewModel, iconSize = 40.dp, heartSize = 35.dp) + ZapReaction(baseNote, accountViewModel, iconSize = 40.dp, animationSize = 35.dp) + ViewCountReaction(baseNote.idHex, iconSize = 40.dp, barChartSize = 39.dp) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun NewImageButton(accountViewModel: AccountViewModel, navController: NavController) { + var wantsToPost by remember { + mutableStateOf(false) + } + + var pickedURI by remember { + mutableStateOf(null) + } + + if (wantsToPost) { + val cameraPermissionState = + rememberPermissionState( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + ) + + if (cameraPermissionState.status.isGranted) { + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelect( + onImageUri = { uri -> + wantsToPost = false + showGallerySelect = false + pickedURI = uri + } + ) + } + + showGallerySelect = true + } else { + LaunchedEffect(key1 = accountViewModel) { + cameraPermissionState.launchPermissionRequest() + } + } + } + + pickedURI?.let { + NewMediaView(it, onClose = { pickedURI = null }, accountViewModel = accountViewModel, navController = navController) + } + + OutlinedButton( + onClick = { wantsToPost = true }, + modifier = Modifier.size(55.dp), + shape = CircleShape, + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White + ) + } +} diff --git a/app/src/main/res/drawable/ic_video.xml b/app/src/main/res/drawable/ic_video.xml new file mode 100644 index 000000000..124419fa2 --- /dev/null +++ b/app/src/main/res/drawable/ic_video.xml @@ -0,0 +1,9 @@ + + + From ab330fea9693ba940643e52e465d7d604718cf45 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:29:42 -0400 Subject: [PATCH 106/164] v0.39.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3b8cf318c..f48bbafc0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 132 - versionName "0.38.0" + versionCode 133 + versionName "0.39.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 8b012884b1fdec773f7f3cdc538a4de72b9bfca9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:31:00 -0400 Subject: [PATCH 107/164] 0.39.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3b8cf318c..f48bbafc0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 132 - versionName "0.38.0" + versionCode 133 + versionName "0.39.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 635ed1afc1a2b8f3ec08c20e1c4418db89885820 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:45:27 -0400 Subject: [PATCH 108/164] v0.40.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f48bbafc0..9f1b014a1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 133 - versionName "0.39.0" + versionCode 134 + versionName "0.40.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From a38bbd71688b80b605c619e84bc6fd9315164ffd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 30 Apr 2023 17:00:47 -0400 Subject: [PATCH 109/164] Adding hex index for the Video Feed --- .../vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 70106f2f0..9a85f9406 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -174,7 +174,10 @@ fun SlidingCarousel( pageCount = feed.value.size, state = pagerState, beyondBoundsPageCount = 1, - modifier = Modifier.fillMaxSize(1f) + modifier = Modifier.fillMaxSize(1f), + key = { index -> + feed.value.getOrNull(index)?.idHex ?: "$index" + } ) { index -> feed.value.getOrNull(index)?.let { note -> RenderVideoOrPictureNote(note, accountViewModel, navController) From 446135ea2ac029cb32e5a4f20e306aa2abe033f8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 30 Apr 2023 21:30:30 -0400 Subject: [PATCH 110/164] Adding Zap Payments to the calculation of amounts and zapped by --- .../vitorpamplona/amethyst/model/Account.kt | 24 +++++- .../amethyst/model/LocalCache.kt | 40 ++++++++-- .../com/vitorpamplona/amethyst/model/Note.kt | 75 +++++++++++++++++-- .../amethyst/service/FileHeader.kt | 9 ++- .../amethyst/service/model/LnZapEvent.kt | 2 +- .../amethyst/ui/note/PollNote.kt | 9 +-- .../amethyst/ui/note/PollNoteViewModel.kt | 7 +- .../amethyst/ui/note/ReactionsRow.kt | 6 +- .../ui/screen/loggedIn/AccountViewModel.kt | 5 ++ .../ui/screen/loggedIn/ProfileScreen.kt | 2 +- 10 files changed, 153 insertions(+), 26 deletions(-) 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 d80373eaf..9279654c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import nostr.postr.Persona +import java.math.BigDecimal import java.util.Locale val DefaultChannels = setOf( @@ -170,7 +171,26 @@ class Account( return zapPaymentRequest != null } - fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) { + fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? { + val myNip47 = zapPaymentRequest ?: return null + return zapResponseEvent.response( + myNip47.secret?.toByteArray() ?: loggedIn.privKey!!, + myNip47.pubKeyHex.toByteArray() + ) + } + + fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean { + return zappedNote.isZappedBy(userProfile(), this) == true + } + + fun calculateZappedAmount(zappedNote: Note?): BigDecimal { + return zappedNote?.zappedAmount( + zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!, + zapPaymentRequest?.pubKeyHex?.toByteArray() + ) ?: BigDecimal.ZERO + } + + fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) { if (!isWriteable()) return zapPaymentRequest?.let { nip47 -> @@ -179,7 +199,7 @@ class Account( val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id) wcListener.start() - LocalCache.consume(event) { + LocalCache.consume(event, zappedNote) { // After the response is received. val privKey = nip47.secret?.toByteArray() if (privKey != 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 a368fa75b..78c58dac6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -4,7 +4,6 @@ import android.util.Log import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledInsert @@ -31,7 +30,8 @@ object LocalCache { val channels = ConcurrentHashMap() val addressables = ConcurrentHashMap(100) - val awaitingPaymentRequests = ConcurrentHashMap Unit>(10) + val awaitingPaymentRequests = + ConcurrentHashMap Unit>>(10) fun checkGetOrCreateUser(key: String): User? { if (isValidHexNpub(key)) { @@ -379,6 +379,7 @@ object LocalCache { masterNote.removeBoost(deleteNote) masterNote.removeReaction(deleteNote) masterNote.removeZap(deleteNote) + masterNote.removeZapPayment(deleteNote) masterNote.removeReport(deleteNote) } @@ -728,12 +729,41 @@ object LocalCache { // Does nothing without a response callback. } - fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) { - awaitingPaymentRequests.put(event.id, onResponse) + fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + zappedNote?.addZapPayment(note, null) + + awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) + + refreshObservers(note) } fun consume(event: LnZapPaymentResponseEvent) { - val responseCallback = awaitingPaymentRequests[event.requestId()] + val requestId = event.requestId() + val pair = awaitingPaymentRequests[requestId] ?: return + + val (zappedNote, responseCallback) = pair + + val requestNote = requestId?.let { checkGetOrCreateNote(requestId) } + + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + requestNote?.let { request -> + zappedNote?.addZapPayment(request, note) + } if (responseCallback != null) { responseCallback(event) 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 4c4959398..c1afee79b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource +import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.EOSETime @@ -45,6 +46,8 @@ open class Note(val idHex: String) { private set var zaps = mapOf() private set + var zapPayments = mapOf() + private set var relays = setOf() private set @@ -156,6 +159,17 @@ open class Note(val idHex: String) { } } + fun removeZapPayment(note: Note) { + if (zapPayments[note] != null) { + zapPayments = zapPayments.minus(note) + liveSet?.zaps?.invalidateData() + } else if (zapPayments.containsValue(note)) { + val toRemove = zapPayments.filterValues { it == note } + zapPayments = zapPayments.minus(toRemove.keys) + liveSet?.zaps?.invalidateData() + } + } + fun addBoost(note: Note) { if (note !in boosts) { boosts = boosts + note @@ -174,6 +188,17 @@ open class Note(val idHex: String) { } } + @Synchronized + fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) { + if (zapPaymentRequest !in zapPayments.keys) { + zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) + liveSet?.zaps?.invalidateData() + } else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) { + zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) + liveSet?.zaps?.invalidateData() + } + } + fun addReaction(note: Note) { if (note !in reactions) { reactions = reactions + note @@ -204,6 +229,14 @@ open class Note(val idHex: String) { // Zaps who the requester was the user return zaps.any { it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex + } || zapPayments.any { + val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent + val response = if (zapResponseEvent != null) { + account.decryptZapPaymentResponseEvent(zapResponseEvent) + } else { + null + } + response is PayInvoiceSuccessResponse && zapResponseEvent?.requestAuthor() == user.pubkeyHex } } @@ -233,12 +266,44 @@ open class Note(val idHex: String) { }.flatten() } - fun zappedAmount(): BigDecimal { - return zaps.mapNotNull { it.value?.event } + fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal { + // Regular Zap Receipts + val completedZaps = zaps.asSequence() + .mapNotNull { it.value?.event } .filterIsInstance() - .mapNotNull { - it.amount - }.sumOf { it } + .filter { it.amount != null } + .associate { + it.lnInvoice() to it.amount + } + .toMap() + + val completedPayments = if (privKey != null && walletServicePubkey != null) { + // Payments confirmed by the User's Wallet + zapPayments + .asSequence() + .filter { + val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey) + response is PayInvoiceSuccessResponse + } + .associate { + val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey) + val amount = try { + if (lnInvoice == null) { + null + } else { + LnInvoiceUtil.getAmountInSats(lnInvoice) + } + } catch (e: java.lang.Exception) { + null + } + lnInvoice to amount + } + .toMap() + } else { + emptyMap() + } + + return (completedZaps + completedPayments).values.filterNotNull().sumOf { it } } fun hasPledgeBy(user: User): Boolean { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index 4c21971e3..f5dd55142 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -19,9 +19,14 @@ class FileHeader( ) { companion object { fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { - val imageData = URL(fileUrl).readBytes() + try { + val imageData = URL(fileUrl).readBytes() - prepare(imageData, fileUrl, mimeType, description, onReady, onError) + prepare(imageData, fileUrl, mimeType, description, onReady, onError) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") + onError() + } } fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { 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 92384c92b..a326f5795 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 @@ -57,7 +57,7 @@ class LnZapEvent( return content } - private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) + fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 2d7f76f8c..b5a4a91f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -56,8 +56,7 @@ fun PollNote( val account = accountState?.account ?: return val pollViewModel = PollNoteViewModel() - pollViewModel.account = account - pollViewModel.load(zappedNote) + pollViewModel.load(account, zappedNote) pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> OptionNote( @@ -212,7 +211,7 @@ fun ZapVote( clickablePrepend: @Composable () -> Unit ) { val zapsState by baseNote.live().zaps.observeAsState() - val zappedNote = zapsState?.note + val zappedNote = zapsState?.note ?: return var wantsToZap by remember { mutableStateOf(false) } @@ -253,7 +252,7 @@ fun ZapVote( ) .show() } - } else if (accountViewModel.isLoggedUser(zappedNote?.author)) { + } else if (accountViewModel.isLoggedUser(zappedNote.author)) { scope.launch { Toast .makeText( @@ -376,7 +375,7 @@ fun ZapVote( LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { - wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true + wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 3ad8ac90f..73c273806 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -20,8 +20,10 @@ class PollNoteViewModel { var consensusThreshold: BigDecimal? = null var totalZapped: BigDecimal = BigDecimal.ZERO + var wasZappedByAuthor: Boolean = false - fun load(note: Note?) { + fun load(acc: Account, note: Note?) { + account = acc pollNote = note pollEvent = pollNote?.event as PollNoteEvent pollOptions = pollEvent?.pollOptions() @@ -31,13 +33,14 @@ class PollNoteViewModel { closedAt = pollEvent?.getTagInt(CLOSED_AT) totalZapped = totalZapped() + wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false } fun canZap(): Boolean { val account = account ?: return false val user = account.userProfile() ?: return false val note = pollNote ?: return false - return user != note.author && !note.isZappedBy(user, account) + return user != note.author && !wasZappedByAuthor } fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum 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 27c377036..310c2c7df 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 @@ -309,7 +309,7 @@ fun ZapReaction( val account = accountState?.account ?: return val zapsState by baseNote.live().zaps.observeAsState() - val zappedNote = zapsState?.note + val zappedNote = zapsState?.note ?: return val zapMessage = "" var wantsToZap by remember { mutableStateOf(false) } @@ -326,7 +326,7 @@ fun ZapReaction( LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { - wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true + wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) } } } @@ -459,7 +459,7 @@ fun ZapReaction( LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { - zapAmount = zappedNote?.zappedAmount() + zapAmount = account.calculateZappedAmount(zappedNote) } } 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 cad01018d..05e5c7fa6 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 @@ -59,6 +59,10 @@ class AccountViewModel(private val account: Account) : ViewModel() { zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType) } + fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean { + return account.calculateIfNoteWasZappedByAccount(zappedNote) + } + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() @@ -88,6 +92,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { if (account.hasWalletConnectSetup()) { account.sendZapPaymentRequestFor( bolt11 = it, + note, onResponse = { response -> if (response is PayInvoiceErrorResponse) { onProgress(0.0f) 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 26a563099..b23bb3623 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 @@ -565,7 +565,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode onSuccess = { // pay directly if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) { response -> + account.sendZapPaymentRequestFor(it, null) { response -> if (response is PayInvoiceSuccessResponse) { scope.launch { Toast.makeText( From cc9d4c237a8b22d6025676e9434998f735ea33f0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 30 Apr 2023 21:31:37 -0400 Subject: [PATCH 111/164] Fixes Bug with the Nostr's Report use of double asterisk --- .../com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt | 1 - 1 file changed, 1 deletion(-) 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 2cdac42f4..f833be657 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 @@ -89,7 +89,6 @@ fun RichTextViewer( if (content.startsWith("> ") || content.startsWith("# ") || content.contains("##") || - content.contains("**") || content.contains("__") || content.contains("```") ) { From 63cc00976f114955f23e4e9de7b9b7167d090768 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 30 Apr 2023 21:34:19 -0400 Subject: [PATCH 112/164] Fixes https://github.com/vitorpamplona/amethyst/issues/387 --- .../com/vitorpamplona/amethyst/service/FileHeader.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index 4c21971e3..f5dd55142 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -19,9 +19,14 @@ class FileHeader( ) { companion object { fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { - val imageData = URL(fileUrl).readBytes() + try { + val imageData = URL(fileUrl).readBytes() - prepare(imageData, fileUrl, mimeType, description, onReady, onError) + prepare(imageData, fileUrl, mimeType, description, onReady, onError) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") + onError() + } } fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { From 22ba150c4e86723d2c85a1fcea8b1fcb7a1d016c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 30 Apr 2023 21:35:53 -0400 Subject: [PATCH 113/164] v0.40.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 9f1b014a1..7db74552e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 134 - versionName "0.40.0" + versionCode 135 + versionName "0.40.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 2b2028853e13759f3b4f7a6d6cd48bad01c9020f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 11:12:46 -0400 Subject: [PATCH 114/164] Finishes the payment database --- .../vitorpamplona/amethyst/model/Account.kt | 9 +++- .../com/vitorpamplona/amethyst/model/Note.kt | 2 +- .../amethyst/service/model/Event.kt | 1 + .../model/LnZapPaymentResponseEvent.kt | 50 ++++++++++++++++++- 4 files changed, 58 insertions(+), 4 deletions(-) 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 9279654c3..06ec6d96f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import nostr.postr.Persona +import nostr.postr.Utils import java.math.BigDecimal import java.util.Locale @@ -171,6 +172,12 @@ class Account( return zapPaymentRequest != null } + fun isNIP47Author(pubkeyHex: String?): Boolean { + val privKey = zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!! + val pubKey = Utils.pubkeyCreate(privKey).toHexKey() + return (pubKey == pubkeyHex) + } + fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? { val myNip47 = zapPaymentRequest ?: return null return zapResponseEvent.response( @@ -203,7 +210,7 @@ class Account( // After the response is received. val privKey = nip47.secret?.toByteArray() if (privKey != null) { - onResponse(it.response(privKey, event.pubKey.toByteArray())) + onResponse(it.response(privKey, nip47.pubKeyHex.toByteArray())) } } 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 c1afee79b..c74ed7997 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -236,7 +236,7 @@ open class Note(val idHex: String) { } else { null } - response is PayInvoiceSuccessResponse && zapResponseEvent?.requestAuthor() == user.pubkeyHex + response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor()) } } 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 508f36487..e25753d0b 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 @@ -207,6 +207,7 @@ open class Event( .registerTypeAdapter(Event::class.java, EventDeserializer()) .registerTypeAdapter(ByteArray::class.java, ByteArraySerializer()) .registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer()) + .registerTypeAdapter(Response::class.java, ResponseDeserializer()) .create() fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt index 9f6304336..fee398c69 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -1,9 +1,14 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey import nostr.postr.Utils +import java.lang.reflect.Type class LnZapPaymentResponseEvent( id: HexKey, @@ -35,8 +40,13 @@ class LnZapPaymentResponseEvent( } fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try { + println("Response Arrived: Decrypting") if (content.isNotEmpty()) { - gson.fromJson(decrypt(privKey, pubKey), Response::class.java) + val decrypted = decrypt(privKey, pubKey) + println("Response Arrived: Parsing $decrypted") + val response = gson.fromJson(decrypted, Response::class.java) + println("Response Arrived: Decrypted ${response?.resultType}") + response } else { null } @@ -58,7 +68,7 @@ abstract class Response( // PayInvoice Call -class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) : +class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) : Response("pay_invoice") { class PayInvoiceResultParams(val preimage: String) } @@ -86,3 +96,39 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) : OTHER // Other error. } } + +class ResponseDeserializer : + JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): Response? { + val jsonObject = json.asJsonObject + val resultType = jsonObject.get("result_type")?.asString + + if (resultType == "pay_invoice") { + val result = jsonObject.get("result")?.asJsonObject + val error = jsonObject.get("error")?.asJsonObject + if (result != null) { + return context.deserialize(jsonObject, PayInvoiceSuccessResponse::class.java) + } + if (error != null) { + return context.deserialize(jsonObject, PayInvoiceErrorResponse::class.java) + } + } else { + // tries to guess + if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) { + return context.deserialize(jsonObject, PayInvoiceSuccessResponse::class.java) + } + if (jsonObject.get("error")?.asJsonObject?.get("code") != null) { + return context.deserialize(jsonObject, PayInvoiceErrorResponse::class.java) + } + } + return null + } + + companion object { + } +} From 15d94ca9c410eeab8d2bebb4956db2accb3a97de Mon Sep 17 00:00:00 2001 From: vivganes Date: Mon, 1 May 2023 21:15:55 +0530 Subject: [PATCH 115/164] Add tamil translations --- app/src/main/res/values-ta/strings.xml | 286 +++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 app/src/main/res/values-ta/strings.xml diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml new file mode 100644 index 000000000..843b2972b --- /dev/null +++ b/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,286 @@ + + + QR குறியீட்டைச் சுட்டிக்காட்டவும் + QR ஐக் காட்டவும் + சுயவிவரப் படம் + QR ஐ ஸ்கேன் செய்யவும் + பரவாயில்லை, காட்டவும் + குறிப்பு தகாதது என குறிக்கப்பட்டது. குறித்தவர் + குறிப்பு கிடைக்கவில்லை + Channel Image + குறிப்பிடப்பட்ட நிகழ்வு கிடைக்கவில்லை + செய்தியை மறைகுறியாக்க முடியவில்லை + குழுப் படம் + வெளிப்படையான உள்ளடக்கம் + ஸ்பேம் + ஆள்மாறாட்டம் + சட்டவிரோத நடத்தை + தெரியாத + ரிலே ஐகான் + எழுதியவர் தெரியவில்லை + உரையை நகலெடுக்கவும் + எழுதியவர் IDஐ நகலெடுக்கவும் + குறிப்பின் IDஐ நகலெடுக்கவும் + ஒளிபரப்பு + நீக்குவதற்கு கோரிக்கை செய் + பயனரை முடக்கு + மறை + ஸ்பேம் / மோசடி என்று புகாரளிக்கவும் + ஆள்மாறாட்டத்தைப் புகாரளிக்கவும் + வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும் + சட்டவிரோத நடத்தையைப் புகாரளிக்கவும் + பதிலளிக்க ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக + குறிப்புகளை உயர்த்த ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக + குறிப்புகளை விரும்புவதற்கு தனிப்பட்ட சாவியுடன் உள்நுழைக + ஜாப் தொகை அமைப்பு இல்லை. மாற்ற நீண்ட நேரம் அழுத்தவும் + ஜாப்களை அனுப்ப ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக + ஜாப்கள் + பார்வை எண்ணிக்கை + உயர்த்து + உயர்த்தப்பட்டது + மேற்கோள் காட்டு + புதிய தொகை (ஸாட்களில்) + சேர் + "பதில் பெறுநர் " + மற்றும் + "அலை வரிசையில் " + சுயவிவர பேனர் + " பின்பற்றப் படுவோர்" + " பின்பற்றுவோர்" + சுயவிவரம் + பாதுகாப்பு வடிப்பான்கள் + வெளியேறு + மேலும் காட்டு + லைட்நிங் விலைப்பட்டியல் + செலுத்து + லைட்நிங் இனாம் + பெறுபவருக்கான குறிப்பு + மிக்க நன்றி! + தொகை (ஸாட்களில்) + ஸாட்கள் அனுப்பவும் + "%1$s க்கான முன்னோட்டத்தை புரிந்து கொள்வதில் பிழை: %2$s" + " %1$s க்கான முன்னோட்ட அட்டைப் படம்" + புதிய அலைவரிசை + அலைவரிசைப் பெயர் + எனது அருமையான குழு + பட Url + தகவல் + "எங்களைப் பற்றி.. " + உங்கள் மனதில் என்ன உள்ளது? + குறிப்பு + சேமி + உருவாக்கு + ரத்து + படத்தை பதிவேற்றுதல் தோல்வி + ரிலே முகவரி + குறிப்புகள் + பைட்டுகள் + பிழைகள் + முகப்பு ஊட்டம் + தனிப்பட்ட செய்தி ஊட்டம் + பொது செய்தி ஊட்டம் + முழுதளாவிய ஊட்டம் + தேடுதல் ஊட்டம் + ஒரு ரிலேவை சேர் + காட்சிப் பெயர் + எனது காட்சிப் பெயர் + பயனர் பெயர் + எனது பயனர் பெயர் + என்னைப் பற்றி + சுயவிவரப் படம் URL + பேனர் URL + வலைத்தள URL + LN முகவரி + LN URL (காலாவதியானது) + படம் கேலரியில் சேமிக்கப் பட்டது + படத்தை சேமிப்பது தோல்வியடைந்தது + படத்தை பதிவேற்று + பதிவேற்றம்... + பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை + "இங்கு பதிலளிக்கவும்.. " + குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும் + அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு + அலைவரிசை பற்றிய தகவல்களை மாற்றும் + சேர் + தெரிந்த + புதிய கோரிக்கைகள் + முடக்கப்பட்ட பயனர்கள் + புதிய நூல்கள் + உரையாடல்கள் + குறிப்புகள் + பதில்கள் + "பின்தொடர படுவோர்" + "புகார்கள்" + மேலும் + " ரிலேகள்" + இணையதளம் + லைட்னிங் முகவரி + Nsec IDயை (உங்கள் கடவுச்சொல்) காப்புப்பிரதிக்காக கிளிப்போர்டுக்கு நகலெடுக்கிறது + கிளிப்போர்டுக்கு ரகசிய சாவியை நகலெடுக்கவும் + பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது + கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும் + நேரடி செய்தியை அனுப்பவும் + பயனரின்தகவலை திருத்துகிறது + பின்தொடர் + பதிலுக்கு பின்தொடரவும் + தடையை நீக்கு + பயனர் IDயை நகலெடுக்கவும் + பயனரைத் தடைசெய்க + "npub, hex, பயனர்பெயர் " + அழி + பயன்பாட்டு லோகோ + nsec / npub / hex தனிப்பட்ட சாவி + கடவுச்சொல்லை காட்டவும் + கடவுச்சொல்லை மறைக்கவும் + தவறான சாவி + "பின்வருவதை நான் ஏற்றுக்கொள்கிறேன்: " + பயன்பாட்டு விதிமுறைகளை + விதிமுறைகளை ஏற்றுக்கொள்வது தேவை + சாவி தேவை + உள்நுழை + புதிய சாவியை உருவாக்குங்கள் + ஊட்டம் ஏற்றப்படுகிறது + "பதில்களை ஏற்றுவதில் பிழை: " + மீண்டும் முயற்சி செய் + ஊட்டம் காலியாக உள்ளது. + புதுப்பி + உருவாக்கப்பட்டது + விளக்கம்: + மற்றும் படம் + மாற்றப்பட்ட அரட்டை பெயர் + விளக்கம் + மற்றும் படம் + வெளியேறு + பின்தொடர்வதை நிறுத்து + அலைவரிசை உருவாக்கப் பட்டது + "அலைவரிசை தகவல் மாற்றப்பட்டது" + பொது அரட்டை + குறிப்புகள் பெறப்பட்டன + அகற்று + தானியங்கி + மொழிபெயர்க்கப்பட்டுள்ளது மூலம்: + + முதலில் %1$s இல் காண்பி + எப்போதும் %1$s க்கு மொழிபெயர்க்கவும் + %1$s இலிருந்து ஒருபோதும் மொழிபெயர்க்க வேண்டாம் + ஒருபோதும் இல்லை + இப்போது + h + m + d + நிர்வாணம் + அவதூறு / வெறுக்கத்தக்க பேச்சு + வெறுக்கத்தக்க பேச்சைப் புகாரளிக்கவும் + நிர்வாணம் / ஆபாசத்தைப் புகாரளிக்கவும் + மற்றவைகள் + அனைத்து தெரிந்தவைகளையும் படித்ததாக குறிக்கவும் + அனைத்து புதியதையும் படித்ததாக குறிக்கவும் + அனைத்தையும் படித்ததாக குறிக்கவும் + காப்பு சாவிகள் + + ## முக்கிய காப்புப்பிரதி மற்றும் பாதுகாப்பு உதவிக்குறிப்புகள் + \n\n உங்கள் கணக்கு ஒரு ரகசியசாவியால் பாதுகாக்கப்படுகிறது. சாவி **nsec1** என்று தொடங்கும் நீண்ட சீரற்ற எழுத்து வடிவம். உங்கள் ரகசிய சாவியை அணுகக்கூடிய எவரும் உங்கள் அடையாளத்தைப் பயன்படுத்தி உள்ளடக்கத்தை வெளியிடலாம். + \n\n- நீங்கள் நம்பாத எந்த வலைத்தளம் அல்லது மென்பொருளிலும் உங்கள் ரகசிய விசையை வைக்க **வேண்டாம்**. + \ n- அமேதிஸ்ட் டெவலப்பர்கள் உங்கள் ரகசியசாவியை உங்களிடம் எப்போதும் கேட்க மாட்டார்கள். + \ n- கணக்கு மீட்டெடுப்பதற்கு உங்கள் ரகசியசாவியின் பாதுகாப்பான காப்புப்பிரதியை **தவறாமல்** வைத்திருங்கள். கடவுச்சொல் நிர்வாகியைப் பயன்படுத்த பரிந்துரைக்கிறோம். + + ரகசிய சாவி (nsec) கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது + எனது ரகசிய சாவியை நகலெடுக்கவும் + அங்கீகரிப்பு தோல்வியுற்றது + பிழை + " %1$s ஆல் உருவாக்கப்பட்டது" + " %1$s க்கான பேட்ஜ் விருது படம்" + நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள் + பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்: + குறிப்பு கிளிப்போர்டுக்கு நகலெடுக்கப் பட்டது + கிளிப்போர்டுக்கு எழுத்தாளரின் @npub நகலெடுக்கப் பட்டது + கிளிப்போர்டுக்கு குறிப்பு ஐடி (@note1) நகலெடுக்கப் பட்டது + உரையைத் தேர்ந்தெடுக்கவும் + "தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள்." + புதிய கணக்கைச் சேர்க்கவும் + கணக்குகள் + கணக்கைத் தேர்ந்தெடுக்கவும் + புதிய கணக்கைச் சேர்க்கவும் + செயலில் உள்ள கணக்கு + தனிப்பட்ட சாவி உள்ளது + படிக்க மட்டும், தனிப்பட்ட சாவி இல்லை + பின் + தேர்ந்தெடுக்கவும் + உலாவி இணைப்பைப் பகிரவும் + பகிர் + ஆசிரியர் ஐடி + குறிப்பு ஐடி + உரையை நகலெடுக்கவும் + அழி + பின்தொடர்வதை நிறுத்து + பின்தொடர் + நீக்க கோரிக்கை செய் + நீங்கள் தற்போது இணைக்கப்பட்டுள்ள ரிலேக்களிலிருந்து உங்கள் குறிப்பை நீக்குமாறு அமேதிஸ்ட் கோரும். உங்கள் குறிப்பு அந்த ரிலேக்களிலிருந்து அல்லது அது சேமிக்கப்பட்டுள்ள பிற ரிலேக்களிலிருந்து நிரந்தரமாக நீக்கப்படும் என்பதற்கு எந்த உத்தரவாதமும் இல்லை. + தடைசெய் + அழி + தடைசெய் + புகார் + அழி + மீண்டும் காட்ட வேண்டாம் + ஸ்பேம் அல்லது மோசடிகள் + அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை + தீங்கிழைக்கும் ஆள்மாறாட்டம் + நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம் + சட்டவிரோத நடத்தை + ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட பயனர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர். + பயனரைத் தடுத்து மறைக்கவும் + தகாதது என பதிவுசெய் + இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும். + உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்… + கூடுதல் சூழல் + காரணம் + ஒரு காரணத்தைத் தேர்ந்தெடுக்கவும்… + புகார் அளிக்கவும் + தடு மற்றும் புகாரளி + தடு + புக்மார்க்குகள் + தனிப்பட்ட புக்மார்க்குகள் + பொது புக்மார்க்குகள் + தனிப்பட்ட புக்மார்க்குகளில் சேர்க்கவும் + பொது புக்மார்க்குகளில் சேர்க்கவும் + தனிப்பட்ட புக்மார்க்குகளிலிருந்து அகற்று + பொது புக்மார்க்குகளிலிருந்து அகற்று + பணப்பை இணைப்பு சேவை + பயன்பாட்டை விட்டு வெளியேறாமல் ஜாப்களை செலுத்த ஒரு நாஸ்டர் ரகசியத்தை அங்கீகரிக்கிறது. ரகசியத்தை பாதுகாப்பாக வைத்து, முடிந்தால் தனியார் ரிலேவைப் பயன்படுத்துங்கள் + பணப்பை இணைப்பு பொது சாவி + பணப்பை இணைப்பு ரிலே + பணப்பை இணைப்பு ரகசியம் + ரகசிய சாவியைக் காட்டு + nsec / hex தனிப்பட்ட சாவி + உறுதிமொழி தொகை (ஸாட்களில்) + + தேவையான புலங்கள்: + ஜாப் பெறுநர்கள் + முதன்மை வாக்கெடுப்பு விளக்கம்… + விருப்பம் %s + வாக்கெடுப்பு விருப்ப விளக்கம் + விருப்ப புலங்கள்: + குறைந்தபட்ச ஜாப் + அதிகபட்ச ஜாப் + ஒருமித்த கருத்து + (0–100)% + நிறைவு செய்வது + நாட்களில் + புதிய வாக்குகளுக்கு வாக்கெடுப்பு மூடப்பட்டுள்ளது + ஜாப் தொகை + இந்த வகை வாக்கெடுப்பில் ஒரு பயனருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது + "நிகழ்வு %1$s ஐத் தேடி" + ஒரு பொது செய்தியைச் சேர்க்கவும் + ஒரு தனிப்பட்ட செய்தியைச் சேர்க்கவும் + விலைப்பட்டியல் செய்தியைச் சேர்க்கவும் + உங்கள் எல்லா வேலைகளுக்கும் நன்றி! + உருவாக்கி சேர்க்கவும் + வாக்கெடுப்பு ஆசிரியர்கள் தங்கள் சொந்த வாக்கெடுப்பில் வாக்களிக்க முடியாது. + குறிப்பு பதிவு செய்யப்பட்ட பின்னர் படம் மாற்றப்படவில்லை + படம் மாறிவிட்டது. மாற்றத்தை ஆசிரியர் பார்த்திருக்க மாட்டார் + படத்தைச் சேர்க்கவும் + வீடியோ சேர்க்கவும் + ஆவணத்தைச் சேர்க்கவும் + உருவாக்கி சேர்க்கவும் + உள்ளடக்கங்களின் விளக்கம் + சூரியன் மறையும் வேளையில் வெள்ளை மண் நிறைந்த கடற்கரையில் ஒரு நீலப் படகு + From 2b87930283eb4b88f8c303eb97e231a3b10e00c3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 16:38:35 -0400 Subject: [PATCH 116/164] Removing the need to prune NIP95 events. --- .../java/com/vitorpamplona/amethyst/ServiceManager.kt | 1 - .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 8 -------- 2 files changed, 9 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 3810ddd2c..fbff99b63 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -72,7 +72,6 @@ object ServiceManager { LocalCache.pruneOldAndHiddenMessages(it) LocalCache.pruneHiddenMessages(it) LocalCache.pruneContactLists(it) - LocalCache.pruneFileStorageEvents(it) // LocalCache.pruneNonFollows(it) } } 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 78c58dac6..8572869e5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -811,14 +811,6 @@ object LocalCache { } } - fun pruneFileStorageEvents(account: Account) { - val toBeRemoved = notes.filter { it.value.event is FileStorageEvent } - - toBeRemoved.forEach { - notes.remove(it.key) - } - } - fun pruneOldAndHiddenMessages(account: Account) { channels.forEach { it -> val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) From 90150895936f8e09e0ac4fa30e5d769a568c4874 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 16:39:07 -0400 Subject: [PATCH 117/164] Correctly loading NIP95 events from relays. --- .../vitorpamplona/amethyst/service/NostrVideoDataSource.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index f35fe3cf6..5ad410390 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -1,7 +1,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.model.FileHeaderEvent -import com.vitorpamplona.amethyst.service.model.FileStorageEvent +import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -10,7 +10,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") { fun createGlobalFilter() = TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(FileHeaderEvent.kind, FileStorageEvent.kind), + kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), limit = 200 ) ) From 8f257a7e2d08f4b7839f4afc1a3857e14afb7bde Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 16:42:58 -0400 Subject: [PATCH 118/164] - Displays Video Uploading Progress - Keeps position in the Video feed between screen - Animates to the top when posting - Adds NIP-95 in-disk caching - Fixes the Crossfade for BlurHash on NIP94 and NIP95 events --- .../vitorpamplona/amethyst/model/Account.kt | 8 +- .../amethyst/model/LocalCache.kt | 21 ++- .../amethyst/service/FileHeader.kt | 9 +- .../service/model/BaseTextNoteEvent.kt | 4 - .../model/LnZapPaymentResponseEvent.kt | 6 +- .../amethyst/ui/actions/ImageSaver.kt | 4 +- .../amethyst/ui/actions/NewMediaModel.kt | 111 ++++++++--- .../amethyst/ui/actions/NewMediaView.kt | 30 ++- .../amethyst/ui/actions/NewPostViewModel.kt | 3 +- .../amethyst/ui/actions/SaveToGallery.kt | 5 +- .../amethyst/ui/components/VideoView.kt | 26 ++- .../ui/components/ZoomableContentView.kt | 72 +++---- .../amethyst/ui/note/NoteCompose.kt | 7 +- .../amethyst/ui/screen/LazyListState.kt | 36 +++- .../ui/screen/loggedIn/VideoScreen.kt | 175 +++++++++++++++--- 15 files changed, 393 insertions(+), 124 deletions(-) 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 06ec6d96f..b6f30a581 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -403,7 +403,7 @@ class Account( } } - fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? { + fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair? { if (!isWriteable()) return null val data = FileStorageEvent.create( @@ -422,6 +422,12 @@ class Account( privateKey = loggedIn.privKey!! ) + return Pair(data, signedEvent) + } + + fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? { + if (!isWriteable()) return null + Client.send(data) LocalCache.consume(data, 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 8572869e5..6dff6da3a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1,9 +1,11 @@ package com.vitorpamplona.amethyst.model import android.util.Log +import androidx.core.net.toUri import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledInsert @@ -11,6 +13,9 @@ import fr.acinq.secp256k1.Hex import kotlinx.coroutines.* import nostr.postr.toNpub import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -720,7 +725,21 @@ object LocalCache { // Already processed this event. if (note.event != null) return - note.loadEvent(event, author, emptyList()) + try { + val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95") + cachePath.mkdirs() + val stream = FileOutputStream(File(cachePath, event.id)) + stream.write(event.decode()) + stream.close() + Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}") + } catch (e: IOException) { + Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e) + } + + // this is an invalid event. But we don't need to keep the data in memory. + val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) + + note.loadEvent(eventNoData, author, emptyList()) refreshObservers(note) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index f5dd55142..537b4626e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -29,7 +29,14 @@ class FileHeader( } } - fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { + fun prepare( + data: ByteArray, + fileUrl: String, + mimeType: String?, + description: String?, + onReady: (FileHeader) -> Unit, + onError: () -> Unit + ) { try { val sha256 = MessageDigest.getInstance("SHA-256") 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 bcd47faee..9b03abaa9 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 @@ -86,10 +86,6 @@ open class BaseTextNoteEvent( val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) if (parsed != null) { - if (content.contains("Testing event")) { - println("AAAA $key") - } - try { val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt index fee398c69..42f71f7c5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -40,13 +40,9 @@ class LnZapPaymentResponseEvent( } fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try { - println("Response Arrived: Decrypting") if (content.isNotEmpty()) { val decrypted = decrypt(privKey, pubKey) - println("Response Arrived: Parsing $decrypted") - val response = gson.fromJson(decrypted, Response::class.java) - println("Response Arrived: Decrypted ${response?.resultType}") - response + gson.fromJson(decrypted, Response::class.java) } else { null } 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 ba62883c4..c47da78fb 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 @@ -79,7 +79,7 @@ object ImageSaver { } fun saveImage( - byteArray: ByteArray, + localFile: File, mimeType: String?, context: Context, onSuccess: () -> Any?, @@ -87,7 +87,7 @@ object ImageSaver { ) { try { val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - val buffer = byteArray.inputStream().source().buffer() + val buffer = localFile.inputStream().source().buffer() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { saveContentQ( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 2c4a89999..86fd92b32 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.Context import android.net.Uri +import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -13,6 +14,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch +import java.net.URL open class NewMediaModel : ViewModel() { var account: Account? = null @@ -27,6 +29,11 @@ open class NewMediaModel : ViewModel() { // Images and Videos var galleryUri by mutableStateOf(null) + var uploadingPercentage = mutableStateOf(0.0f) + var uploadingDescription = mutableStateOf(null) + + var onceUploaded: () -> Unit = {} + open fun load(account: Account, uri: Uri, contentType: String?) { this.account = account this.galleryUri = uri @@ -40,7 +47,7 @@ open class NewMediaModel : ViewModel() { } } - fun upload(context: Context, onClose: () -> Unit) { + fun upload(context: Context) { isUploadingImage = true val contentResolver = context.contentResolver @@ -48,23 +55,32 @@ open class NewMediaModel : ViewModel() { val serverToUse = selectedServer ?: return if (selectedServer == ServersAvailable.NIP95) { + uploadingPercentage.value = 0.1f + uploadingDescription.value = "Loading" val contentType = contentResolver.getType(uri) contentResolver.openInputStream(uri)?.use { - createNIP95Record(it.readBytes(), contentType, description, onClose) + createNIP95Record(it.readBytes(), contentType, description) } ?: viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null } } else { + uploadingPercentage.value = 0.1f + uploadingDescription.value = "Uploading" ImageUploader.uploadImage( uri = uri, server = serverToUse, contentResolver = contentResolver, onSuccess = { imageUrl, mimeType -> - createNIP94Record(imageUrl, mimeType, description, onClose) + createNIP94Record(imageUrl, mimeType, description) }, onError = { isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } @@ -77,6 +93,8 @@ open class NewMediaModel : ViewModel() { galleryUri = null isUploadingImage = false mediaType = null + uploadingDescription.value = null + uploadingPercentage.value = 0.0f description = "" selectedServer = account?.defaultFileServer @@ -86,37 +104,68 @@ open class NewMediaModel : ViewModel() { return !isUploadingImage && galleryUri != null && selectedServer != null } - fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) { + fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) { + uploadingPercentage.value = 0.40f viewModelScope.launch(Dispatchers.IO) { + uploadingDescription.value = "Server Processing" // Images don't seem to be ready immediately after upload if (mimeType?.startsWith("image/") == true) { delay(2000) } else { - delay(5000) + delay(15000) } - FileHeader.prepare( - imageUrl, - mimeType, - description, - onReady = { - val note = account?.sendHeader(it) - isUploadingImage = false - cancel() - onClose() - }, - onError = { - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") + uploadingDescription.value = "Downloading" + uploadingPercentage.value = 0.60f + + try { + val imageData = URL(imageUrl).readBytes() + + uploadingPercentage.value = 0.80f + uploadingDescription.value = "Hashing" + + FileHeader.prepare( + imageData, + imageUrl, + mimeType, + description, + onReady = { + uploadingPercentage.value = 0.90f + uploadingDescription.value = "Sending" + val note = account?.sendHeader(it) + uploadingPercentage.value = 1.00f + isUploadingImage = false + onceUploaded() + cancel() + }, + onError = { + cancel() + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } } + ) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") + cancel() + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") } - ) + } } } - fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) { + fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) { + uploadingPercentage.value = 0.20f + uploadingDescription.value = "Hashing" + viewModelScope.launch(Dispatchers.IO) { FileHeader.prepare( bytes, @@ -124,13 +173,26 @@ open class NewMediaModel : ViewModel() { mimeType, description, onReady = { - account?.sendNip95(bytes, headerInfo = it) + uploadingDescription.value = "Signing" + uploadingPercentage.value = 0.30f + val nip95 = account?.createNip95(bytes, headerInfo = it) + + if (nip95 != null) { + uploadingDescription.value = "Sending" + uploadingPercentage.value = 0.60f + account?.sendNip95(nip95.first, nip95.second) + } + + uploadingPercentage.value = 1.00f isUploadingImage = false + onceUploaded() cancel() - onClose() }, onError = { + uploadingDescription.value = null + uploadingPercentage.value = 0.00f isUploadingImage = false + cancel() viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } @@ -142,4 +204,7 @@ open class NewMediaModel : ViewModel() { fun isImage() = mediaType?.startsWith("image") fun isVideo() = mediaType?.startsWith("video") fun defaultServer() = account?.defaultFileServer + fun onceUploaded(onceUploaded: () -> Unit) { + this.onceUploaded = onceUploaded + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index b50b1843e..1ca8ca51b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R @@ -36,23 +35,20 @@ import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable -fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) { +fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) { val account = accountViewModel.accountLiveData.value?.account ?: return val resolver = LocalContext.current.contentResolver - val postViewModel: NewMediaModel = viewModel() val context = LocalContext.current val scroolState = rememberScrollState() - LaunchedEffect(Unit) { - val mediaType = resolver.getType(uri) ?: "" - postViewModel.load(account, uri, mediaType) - delay(100) + val mediaType = resolver.getType(uri) ?: "" + postViewModel.load(account, uri, mediaType) + LaunchedEffect(Unit) { postViewModel.imageUploadingError.collect { error -> Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } @@ -85,15 +81,11 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewMod onClose() }) - if (postViewModel.isUploadingImage) { - LoadingAnimation() - } - PostButton( onPost = { - postViewModel.upload(context) { - onClose() - } + onClose() + postViewModel.upload(context) + postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) } }, isActive = postViewModel.canPost() ) @@ -154,7 +146,11 @@ fun ImageVideoPost(postViewModel: NewMediaModel) { LaunchedEffect(key1 = postViewModel.galleryUri) { scope.launch(Dispatchers.IO) { postViewModel.galleryUri?.let { - bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) + try { + bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) + } catch (e: Exception) { + postViewModel.imageUploadingError.emit("Unable to load file") + } } } } @@ -182,7 +178,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) { ) { TextSpinner( label = stringResource(id = R.string.file_server), - placeholder = fileServers.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.second ?: fileServers[0].second, + placeholder = fileServers.firstOrNull { it.first == postViewModel.selectedServer }?.second ?: fileServers[0].second, options = fileServerOptions, explainers = fileServerExplainers, onSelect = { 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 22c7994f4..fd38e3031 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 @@ -329,7 +329,8 @@ open class NewPostViewModel : ViewModel() { mimeType, description, onReady = { - val note = account?.sendNip95(bytes, headerInfo = it) + val nip95 = account?.createNip95(bytes, headerInfo = it) + val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) } isUploadingImage = false 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 1da278cbc..0336877ce 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 @@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import kotlinx.coroutines.launch +import java.io.File /** * A button to save the remote image to the gallery. @@ -86,14 +87,14 @@ fun SaveToGallery(url: String) { @OptIn(ExperimentalPermissionsApi::class) @Composable -fun SaveToGallery(byteArray: ByteArray, mimeType: String?) { +fun SaveToGallery(localFile: File, mimeType: String?) { val localContext = LocalContext.current val scope = rememberCoroutineScope() fun saveImage() { ImageSaver.saveImage( context = localContext, - byteArray = byteArray, + localFile = localFile, mimeType = mimeType, onSuccess = { scope.launch { 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 59c7ec844..e60b543ec 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 @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import com.google.android.exoplayer2.C @@ -22,7 +23,14 @@ import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.StyledPlayerView +import com.google.android.exoplayer2.util.EventLogger import com.vitorpamplona.amethyst.VideoCache +import java.io.File + +@Composable +fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { + VideoView(localFile.toUri(), description, onDialog) +} @Composable fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { @@ -48,15 +56,21 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING - setMediaSource( - ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource( - media + if (videoUri.scheme?.startsWith("file") == true) { + setMediaItem(media) + } else { + setMediaSource( + ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource( + media + ) ) - ) + } prepare() } } + exoPlayer.addAnalyticsListener(EventLogger()) + DisposableEffect( AndroidView( modifier = Modifier.fillMaxWidth(), @@ -95,7 +109,3 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - } } } - -@Composable -fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 775851211..ec0d94d49 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.util.Log import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -71,6 +74,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable +import java.io.File import java.security.MessageDigest abstract class ZoomableContent( @@ -106,7 +110,7 @@ abstract class ZoomablePreloadedContent( ) : ZoomableContent(description) class ZoomableBitmapImage( - val byteArray: ByteArray?, + val localFile: File, val mimeType: String? = null, description: String? = null, val bluehash: String? = null, @@ -115,7 +119,7 @@ class ZoomableBitmapImage( ) : ZoomablePreloadedContent(description, isVerified, uri) class ZoomableBytesVideo( - val byteArray: ByteArray, + val localFile: File, val mimeType: String? = null, description: String? = null, isVerified: Boolean? = null, @@ -166,7 +170,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List } } } else if (content is ZoomableBitmapImage) { - LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + LaunchedEffect(key1 = content.localFile, key2 = imageState) { if (imageState is AsyncImagePainter.State.Success) { scope.launch(Dispatchers.IO) { verifiedHash = content.isVerified @@ -174,12 +178,8 @@ fun ZoomableContentView(content: ZoomableContent, images: List } } } else if (content is ZoomableBytesVideo) { - LaunchedEffect(key1 = content.byteArray, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = content.isVerified - } - } + LaunchedEffect(key1 = content.localFile, key2 = imageState) { + verifiedHash = content.isVerified } } @@ -214,7 +214,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List } if (content is ZoomableUrlImage) { - Box() { + Box(contentAlignment = Alignment.Center) { AsyncImage( model = content.url, contentDescription = content.description, @@ -231,13 +231,16 @@ fun ZoomableContentView(content: ZoomableContent, images: List if (imageState is AsyncImagePainter.State.Success) { HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) } - } - if (imageState !is AsyncImagePainter.State.Success) { - if (content.bluehash != null) { - DisplayBlueHash(content, mainImageModifier) - } else { - DisplayUrlWithLoadingSymbol(content) + AnimatedVisibility( + visible = imageState !is AsyncImagePainter.State.Success, + exit = fadeOut(animationSpec = tween(200)) + ) { + if (content.bluehash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } } } } else if (content is ZoomableUrlVideo) { @@ -245,7 +248,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List } else if (content is ZoomableBitmapImage) { Box() { AsyncImage( - model = content.byteArray, + model = content.localFile, contentDescription = content.description, contentScale = ContentScale.FillWidth, modifier = mainImageModifier, @@ -270,6 +273,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List } } } else if (content is ZoomableBytesVideo) { + VideoView(content.localFile, content.description) { dialogOpen = true } } if (dialogOpen) { @@ -384,8 +388,8 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List() -private data class ScrollState(val index: Int, val scrollOffset: Int) +private data class ScrollState(val index: Int, val scrollOffsetFraction: Float) object ScrollStateKeys { const val GLOBAL_SCREEN = "Global" const val NOTIFICATION_SCREEN = "Notifications" + const val VIDEO_SCREEN = "Video" val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_REPLIES = Route.Home.base + "FollowsReplies" } @@ -25,16 +29,42 @@ fun rememberForeverLazyListState( val scrollState = rememberSaveable(saver = LazyListState.Saver) { val savedValue = savedScrollStates[key] val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex - val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset + val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() LazyListState( savedIndex, - savedOffset + savedOffset.roundToInt() ) } DisposableEffect(Unit) { onDispose { val lastIndex = scrollState.firstVisibleItemIndex val lastOffset = scrollState.firstVisibleItemScrollOffset + savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) + } + } + return scrollState +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun rememberForeverPagerState( + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Float = 0.0f +): PagerState { + val scrollState = rememberSaveable(saver = PagerState.Saver) { + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset + PagerState( + savedIndex, + savedOffset + ) + } + DisposableEffect(Unit) { + onDispose { + val lastIndex = scrollState.currentPage + val lastOffset = scrollState.currentPageOffsetFraction savedScrollStates[key] = ScrollState(lastIndex, lastOffset) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 9a85f9406..1be4f09dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -6,6 +6,8 @@ import android.os.Build import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,10 +24,12 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.runtime.Composable @@ -37,16 +41,27 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -56,10 +71,13 @@ import com.vitorpamplona.amethyst.service.NostrVideoDataSource import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.ui.actions.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.NewMediaModel import com.vitorpamplona.amethyst.ui.actions.NewMediaView import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.LikeReaction @@ -73,6 +91,10 @@ import com.vitorpamplona.amethyst.ui.screen.FeedError import com.vitorpamplona.amethyst.ui.screen.FeedState import com.vitorpamplona.amethyst.ui.screen.LoadingFeed import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable fun VideoScreen( @@ -116,7 +138,7 @@ fun VideoScreen( Column( modifier = Modifier.padding(vertical = 0.dp) ) { - FeedView(videoFeedView, accountViewModel, navController) + FeedView(videoFeedView, accountViewModel, navController, ScrollStateKeys.VIDEO_SCREEN, scrollToTop) } } } @@ -125,7 +147,9 @@ fun VideoScreen( fun FeedView( videoFeedView: NostrVideoFeedViewModel, accountViewModel: AccountViewModel, - navController: NavController + navController: NavController, + scrollStateKey: String? = null, + scrollToTop: Boolean = false ) { val feedState by videoFeedView.feedContent.collectAsState() @@ -148,7 +172,9 @@ fun FeedView( SlidingCarousel( state.feed, accountViewModel, - navController + navController, + scrollStateKey, + scrollToTop ) } @@ -166,9 +192,21 @@ fun FeedView( fun SlidingCarousel( feed: MutableState>, accountViewModel: AccountViewModel, - navController: NavController + navController: NavController, + scrollStateKey: String? = null, + scrollToTop: Boolean = false ) { - val pagerState: PagerState = remember { PagerState() } + val pagerState = if (scrollStateKey != null) { + rememberForeverPagerState(scrollStateKey) + } else { + remember { PagerState() } + } + + if (scrollToTop) { + LaunchedEffect(Unit) { + pagerState.scrollToPage(page = 0) + } + } VerticalPager( pageCount = feed.value.size, @@ -212,14 +250,14 @@ private fun RenderVideoOrPictureNote( Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) { Column(Modifier.weight(1f)) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) { - Column(Modifier.size(45.dp), verticalArrangement = Arrangement.Center) { - NoteAuthorPicture(note, navController, loggedIn, 45.dp) + Column(Modifier.size(55.dp), verticalArrangement = Arrangement.Center) { + NoteAuthorPicture(note, navController, loggedIn, 55.dp) } Column( Modifier .padding(start = 10.dp, end = 10.dp) - .height(45.dp) + .height(60.dp) .weight(1f), verticalArrangement = Arrangement.Center ) { @@ -243,6 +281,9 @@ private fun RenderVideoOrPictureNote( Row(verticalAlignment = Alignment.CenterVertically) { ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f)) } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp)) { + RelayBadges(baseNote = note) + } } } } @@ -260,6 +301,42 @@ private fun RenderVideoOrPictureNote( } } +@Composable +private fun RelayBadges(baseNote: Note) { + val noteRelaysState by baseNote.live().relays.observeAsState() + val noteRelays = noteRelaysState?.note?.relays ?: emptySet() + + var expanded by remember { mutableStateOf(false) } + + val relaysToDisplay = noteRelays + + val uri = LocalUriHandler.current + + FlowRow() { + relaysToDisplay.forEach { + val url = it.removePrefix("wss://").removePrefix("ws://") + Box( + Modifier + .size(15.dp) + .padding(1.dp) + ) { + RobohashFallbackAsyncImage( + robot = "https://$url/favicon.ico", + robotSize = 15.dp, + model = "https://$url/favicon.ico", + contentDescription = stringResource(id = R.string.relay_icon), + colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), + modifier = Modifier + .fillMaxSize(1f) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.background) + .clickable(onClick = { uri.openUri("https://$url") }) + ) + } + } + } +} + @Composable fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -308,6 +385,26 @@ fun NewImageButton(accountViewModel: AccountViewModel, navController: NavControl mutableStateOf(null) } + val scope = rememberCoroutineScope() + + val postViewModel: NewMediaModel = viewModel() + postViewModel.onceUploaded { + scope.launch { + // awaits an refresh on the list + delay(250) + val route = Route.Video.route.replace("{scrollToTop}", "true") + navController.navigate(route) { + navController.graph.startDestinationRoute?.let { start -> + popUpTo(start) { inclusive = false } + restoreState = true + } + + launchSingleTop = true + restoreState = true + } + } + } + if (wantsToPost) { val cameraPermissionState = rememberPermissionState( @@ -339,21 +436,53 @@ fun NewImageButton(accountViewModel: AccountViewModel, navController: NavControl } pickedURI?.let { - NewMediaView(it, onClose = { pickedURI = null }, accountViewModel = accountViewModel, navController = navController) - } - - OutlinedButton( - onClick = { wantsToPost = true }, - modifier = Modifier.size(55.dp), - shape = CircleShape, - colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), - contentPadding = PaddingValues(0.dp) - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - null, - modifier = Modifier.size(26.dp), - tint = Color.White + NewMediaView( + uri = it, + onClose = { pickedURI = null }, + postViewModel = postViewModel, + accountViewModel = accountViewModel, + navController = navController ) } + + if (postViewModel.isUploadingImage) { + ShowProgress(postViewModel) + } else { + OutlinedButton( + onClick = { wantsToPost = true }, + modifier = Modifier.size(55.dp), + shape = CircleShape, + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White + ) + } + } +} + +@Composable +private fun ShowProgress(postViewModel: NewMediaModel) { + Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = postViewModel.uploadingPercentage.value, + modifier = Modifier + .size(55.dp) + .background(MaterialTheme.colors.background) + .clip(CircleShape), + strokeWidth = 5.dp + ) + postViewModel.uploadingDescription.value?.let { + Text( + it, + color = MaterialTheme.colors.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center + ) + } + } } From f889ff25473f19b3a98e9583877194784af7b1f7 Mon Sep 17 00:00:00 2001 From: Believethehype Date: Tue, 2 May 2023 00:23:59 +0200 Subject: [PATCH 119/164] added nostrbuild, can only upload very small files right now --- .../amethyst/ui/actions/ImageUploader.kt | 28 +++++++++++-------- .../amethyst/ui/actions/NewPostView.kt | 1 + app/src/main/res/values/strings.xml | 3 ++ 3 files changed, 21 insertions(+), 11 deletions(-) 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 7dd5bb23f..be6095797 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 @@ -31,26 +31,31 @@ object ImageUploader { checkNotNull(imageInputStream) { "Can't open the image input stream" } - val myServer = if (server == ServersAvailable.IMGUR) { ImgurServer() } else if (server == ServersAvailable.NOSTRIMG) { NostrImgServer() + } else if (server == ServersAvailable.NOSTR_BUILD) { + NostrBuildServer() } else { ImgurServer() } - uploadImage(imageInputStream, contentType, myServer, onSuccess, onError) + uploadImage(imageInputStream, contentType, myServer, server, onSuccess, onError) } fun uploadImage( inputStream: InputStream, contentType: String?, server: FileServer, + serverType: ServersAvailable, onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit ) { - val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + var category = "fileToUpload" + if (serverType != ServersAvailable.NOSTR_BUILD) { + category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + } val fileName = randomChars() val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" @@ -73,16 +78,18 @@ object ImageUploader { .build() val requestBuilder = Request.Builder() - server.clientID()?.let { - requestBuilder.header("Authorization", it) + requestBuilder.addHeader("Authorization", it) + } + + if (serverType == ServersAvailable.NOSTR_BUILD) { + requestBuilder.addHeader("Content-Type", "multipart/form-data") } requestBuilder - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(server.postUrl(contentType)) .post(requestBody) - val request = requestBuilder.build() client.newCall(request).enqueue(object : Callback { @@ -146,12 +153,11 @@ class ImgurServer : FileServer() { } class NostrBuildServer : FileServer() { - override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/amethyst.php" + override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php" override fun parseUrlFromSucess(body: String): String? { - val tree = jacksonObjectMapper().readTree(body) - val url = tree?.get("data")?.get("link")?.asText() - return url + val url = jacksonObjectMapper().readTree(body) + return url.toString().replace("\"", "") } override fun clientID() = null 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 e6fe1e9a4..c008952dd 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 @@ -610,6 +610,7 @@ fun ImageVideoDescription( val fileServers = listOf( Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)), Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)), + Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)), Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 921787a85..7fd0d6d94 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -333,6 +333,9 @@ nostrimg.com - less secure Uploads to NostrImg. NostrImg can change your image at any time + nostr.build- less secure + Uploads to Nostr.build. Nostr.build can change your image at any time + Verifiable Imgur (NIP-94) Protects from Imgur changing your image/video after you post From ff54629601d0a2fa14e3f17b31b9b46eb49f8125 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 18:53:07 -0400 Subject: [PATCH 120/164] Autoplay controls --- .../amethyst/ui/components/VideoView.kt | 183 ++++++++++++++++-- ...yListState.kt => RememberForeverStates.kt} | 1 + .../ui/screen/loggedIn/VideoScreen.kt | 1 - 3 files changed, 167 insertions(+), 18 deletions(-) rename app/src/main/java/com/vitorpamplona/amethyst/ui/screen/{LazyListState.kt => RememberForeverStates.kt} (99%) 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 e60b543ec..73ec63465 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 @@ -1,16 +1,46 @@ package com.vitorpamplona.amethyst.ui.components +import android.content.Context +import android.media.AudioManager import android.net.Uri +import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri import androidx.lifecycle.Lifecycle @@ -20,13 +50,16 @@ import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.StyledPlayerView -import com.google.android.exoplayer2.util.EventLogger +import com.google.android.exoplayer2.util.Util import com.vitorpamplona.amethyst.VideoCache import java.io.File +private var muted = mutableStateOf(true) + @Composable fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { VideoView(localFile.toUri(), description, onDialog) @@ -42,6 +75,14 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - val context = LocalContext.current val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + val myAudioManager = + context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + val myAudioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build() + val exoPlayer = remember(videoUri) { val mediaBuilder = MediaItem.Builder().setUri(videoUri) @@ -56,6 +97,16 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + setAudioAttributes(myAudioAttributes, true) + if (Util.SDK_INT >= 23) { + myAudioManager.adjustStreamVolume( + C.STREAM_TYPE_MUSIC, + if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE, + 0 + ) + } else { + myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value) + } if (videoUri.scheme?.startsWith("file") == true) { setMediaItem(media) } else { @@ -69,28 +120,53 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - } } - exoPlayer.addAnalyticsListener(EventLogger()) - DisposableEffect( - 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 - onDialog?.let { innerOnDialog -> - setFullscreenButtonClickListener { + Box() { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .onVisibilityChanges { visible -> + if (visible) { + exoPlayer.play() + } else { exoPlayer.pause() - innerOnDialog(it) + } + }, + factory = { + StyledPlayerView(context).apply { + player = exoPlayer + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + controllerAutoShow = false + hideController() + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + onDialog?.let { innerOnDialog -> + setFullscreenButtonClickListener { + exoPlayer.pause() + innerOnDialog(it) + } } } } + ) + + MuteButton(muted, Modifier) { + muted.value = !muted.value + if (Util.SDK_INT >= 23) { + myAudioManager.adjustStreamVolume( + C.STREAM_TYPE_MUSIC, + if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE, + 0 + ) + } else { + myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value) + } } - ) + } + ) { val observer = LifecycleEventObserver { _, event -> when (event) { @@ -109,3 +185,76 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - } } } + +fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed { + val view = LocalView.current + var isVisible: Boolean? by remember { mutableStateOf(null) } + + LaunchedEffect(isVisible) { + onVisibilityChanges(isVisible == true) + } + + onGloballyPositioned { coordinates -> + isVisible = coordinates.isCompletelyVisible(view) + } +} + +fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean { + if (!isAttached) return false + // Window relative bounds of our compose root view that are visible on the screen + val globalRootRect = android.graphics.Rect() + if (!view.getGlobalVisibleRect(globalRootRect)) { + // we aren't visible at all. + return false + } + val bounds = boundsInWindow() + // Make sure we are completely in bounds. + return bounds.top >= globalRootRect.top && + bounds.left >= globalRootRect.left && + bounds.right <= globalRootRect.right && + bounds.bottom <= globalRootRect.bottom +} + +@Composable +private fun MuteButton(muted: MutableState, modifier: Modifier, toggle: () -> Unit) { + Box( + modifier + .width(70.dp) + .height(70.dp) + .padding(10.dp) + ) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colors.background) + ) + + if (muted.value) { + IconButton( + onClick = toggle, + modifier = Modifier.size(50.dp) + ) { + Icon( + imageVector = Icons.Default.VolumeOff, + "Hash Verified", + tint = MaterialTheme.colors.onBackground, + modifier = Modifier.size(30.dp) + ) + } + } else { + IconButton( + onClick = toggle, + modifier = Modifier.size(50.dp) + ) { + Icon( + imageVector = Icons.Default.VolumeUp, + "Hash Verified", + tint = MaterialTheme.colors.onBackground, + modifier = Modifier.size(30.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt similarity index 99% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index 13aa6ff46..3ac83010f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.ui.navigation.Route import kotlin.math.roundToInt private val savedScrollStates = mutableMapOf() + private data class ScrollState(val index: Int, val scrollOffsetFraction: Float) object ScrollStateKeys { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 1be4f09dd..401c48435 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -45,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter From 0f080219b29a5627b204c7435de017bf9226cc13 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 18:54:06 -0400 Subject: [PATCH 121/164] Starts on mute --- .../main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt | 3 +++ .../java/com/vitorpamplona/amethyst/ui/components/VideoView.kt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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 e60c431fc..808caf46f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Client +import com.vitorpamplona.amethyst.ui.components.muted import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.Nip47 import com.vitorpamplona.amethyst.ui.screen.AccountScreen @@ -92,6 +93,8 @@ class MainActivity : FragmentActivity() { @OptIn(DelicateCoroutinesApi::class) override fun onResume() { super.onResume() + // starts muted every time + muted.value = true // Only starts after login GlobalScope.launch(Dispatchers.IO) { 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 73ec63465..829fe4fb9 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 @@ -58,7 +58,7 @@ import com.google.android.exoplayer2.util.Util import com.vitorpamplona.amethyst.VideoCache import java.io.File -private var muted = mutableStateOf(true) +public var muted = mutableStateOf(true) @Composable fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { From bc54134e65270e47d6763462b806f6b06b26e228 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 18:54:51 -0400 Subject: [PATCH 122/164] 0.40.2 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7db74552e..6465855e4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 135 - versionName "0.40.1" + versionCode 136 + versionName "0.40.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 82a2586eab6a42f6dce1aff42919789a36c4f291 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 20:42:48 -0400 Subject: [PATCH 123/164] Adds language to warn that NIP94 and 95 are new and other clients might not see it. --- app/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 921787a85..64fcffb60 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -334,10 +334,10 @@ Uploads to NostrImg. NostrImg can change your image at any time Verifiable Imgur (NIP-94) - Protects from Imgur changing your image/video after you post + Protects from Imgur modifying the content afterwards. This is a new NIP: other clients might not see the image Verifiable NostrImg (NIP-94) - Protects from NostrImg changing your image after you post + Protects from NostrImg modifying the content afterwards. This is a new NIP: other clients might not see the image Your relays (NIP-95) Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list From 7d620cd89ad301c137adf3ff1212fb56604ddbe3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 20:43:14 -0400 Subject: [PATCH 124/164] BugFix for now showing the error message when the phone can't open the file --- .../amethyst/ui/actions/NewMediaModel.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 86fd92b32..880192257 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -61,11 +61,13 @@ open class NewMediaModel : ViewModel() { contentResolver.openInputStream(uri)?.use { createNIP95Record(it.readBytes(), contentType, description) } - ?: viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null + ?: run { + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + } } } else { uploadingPercentage.value = 0.1f From 6731ca59cd4bcc38bd1fe5c8eb783884b162faeb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 20:43:31 -0400 Subject: [PATCH 125/164] BugFix for mixing NIP94 and NIP95 selections --- .../amethyst/ui/actions/NewMediaView.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 1ca8ca51b..477ca586d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.navigation.NavController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner @@ -45,10 +46,9 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac val scroolState = rememberScrollState() - val mediaType = resolver.getType(uri) ?: "" - postViewModel.load(account, uri, mediaType) - - LaunchedEffect(Unit) { + LaunchedEffect(uri) { + val mediaType = resolver.getType(uri) ?: "" + postViewModel.load(account, uri, mediaType) postViewModel.imageUploadingError.collect { error -> Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } @@ -101,7 +101,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac .fillMaxWidth() .verticalScroll(scroolState) ) { - ImageVideoPost(postViewModel) + ImageVideoPost(postViewModel, account) } } } @@ -110,7 +110,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac } @Composable -fun ImageVideoPost(postViewModel: NewMediaModel) { +fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) { val scope = rememberCoroutineScope() val fileServers = listOf( @@ -178,7 +178,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) { ) { TextSpinner( label = stringResource(id = R.string.file_server), - placeholder = fileServers.firstOrNull { it.first == postViewModel.selectedServer }?.second ?: fileServers[0].second, + placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second, options = fileServerOptions, explainers = fileServerExplainers, onSelect = { From 486d43dc1ba990b76ee35f4961d2d228c4030b62 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 1 May 2023 20:44:37 -0400 Subject: [PATCH 126/164] 0.40.3 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6465855e4..2ccec96b1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 136 - versionName "0.40.2" + versionCode 137 + versionName "0.40.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 4464247ddb04fa65a1b600d8e2bcf4509b128bff Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 08:20:45 -0400 Subject: [PATCH 127/164] NVK is beautiful. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3192471aa..4ecf0974d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Markdown Support - [x] Relay Authentication (NIP-42) - [x] Content stored in relays themselves (NIP-95) +- [ ] Image/Video Capture in the app - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) From 80bfbf68b4191c830b0583621eb41634fec08f70 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 10:02:45 -0400 Subject: [PATCH 128/164] Sending dimensions to Image files. --- .../vitorpamplona/amethyst/model/Account.kt | 2 + .../amethyst/service/FileHeader.kt | 15 +- .../amethyst/service/model/FileHeaderEvent.kt | 4 + .../service/model/FileStorageHeaderEvent.kt | 4 + .../ui/components/ZoomableContentView.kt | 346 ++++++++---------- .../amethyst/ui/note/NoteCompose.kt | 24 +- 6 files changed, 188 insertions(+), 207 deletions(-) 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 b6f30a581..7d5952379 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -417,6 +417,7 @@ class Account( mimeType = headerInfo.mimeType, hash = headerInfo.hash, size = headerInfo.size.toString(), + dimensions = headerInfo.dim, blurhash = headerInfo.blurHash, description = headerInfo.description, privateKey = loggedIn.privKey!! @@ -445,6 +446,7 @@ class Account( mimeType = headerInfo.mimeType, hash = headerInfo.hash, size = headerInfo.size.toString(), + dimensions = headerInfo.dim, blurhash = headerInfo.blurHash, description = headerInfo.description, privateKey = loggedIn.privKey!! diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index 537b4626e..941aab60e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -14,6 +14,7 @@ class FileHeader( val mimeType: String?, val hash: String, val size: Int, + val dim: String?, val blurHash: String?, val description: String? = null ) { @@ -43,7 +44,7 @@ class FileHeader( val hash = sha256.digest(data).toHexKey() val size = data.size - val blurHash = if (mimeType?.startsWith("image/") == true) { + val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) { val opt = BitmapFactory.Options() opt.inPreferredConfig = Bitmap.Config.ARGB_8888 val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) @@ -59,20 +60,22 @@ class FileHeader( mBitmap.height ) + val dim = "${mBitmap.width}x${mBitmap.height}" + val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() if (aspectRatio > 1) { - BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()) + Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim) } else if (aspectRatio < 1) { - BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9) + Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim) } else { - BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4) + Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim) } } else { - null + Pair(null, null) } - onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description)) + onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description)) } catch (e: Exception) { Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") onError() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt index 5c91fcc80..a33344af6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt @@ -19,6 +19,7 @@ class FileHeaderEvent( fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) @@ -30,6 +31,7 @@ class FileHeaderEvent( private const val ENCRYPTION_KEY = "aes-256-gcm" private const val MIME_TYPE = "m" private const val FILE_SIZE = "size" + private const val DIMENSION = "dim" private const val HASH = "x" private const val MAGNET_URI = "magnet" private const val TORRENT_INFOHASH = "i" @@ -41,6 +43,7 @@ class FileHeaderEvent( description: String? = null, hash: String? = null, size: String? = null, + dimensions: String? = null, blurhash: String? = null, magnetURI: String? = null, torrentInfoHash: String? = null, @@ -53,6 +56,7 @@ class FileHeaderEvent( mimeType?.let { listOf(MIME_TYPE, mimeType) }, hash?.let { listOf(HASH, it) }, size?.let { listOf(FILE_SIZE, it) }, + dimensions?.let { listOf(DIMENSION, it) }, blurhash?.let { listOf(BLUR_HASH, it) }, magnetURI?.let { listOf(MAGNET_URI, it) }, torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt index ae2fd5cdf..f2ba729bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt @@ -20,6 +20,7 @@ class FileStorageHeaderEvent( fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) @@ -30,6 +31,7 @@ class FileStorageHeaderEvent( private const val ENCRYPTION_KEY = "aes-256-gcm" private const val MIME_TYPE = "m" private const val FILE_SIZE = "size" + private const val DIMENSION = "dim" private const val HASH = "x" private const val MAGNET_URI = "magnet" private const val TORRENT_INFOHASH = "i" @@ -41,6 +43,7 @@ class FileStorageHeaderEvent( description: String? = null, hash: String? = null, size: String? = null, + dimensions: String? = null, blurhash: String? = null, magnetURI: String? = null, torrentInfoHash: String? = null, @@ -53,6 +56,7 @@ class FileStorageHeaderEvent( mimeType?.let { listOf(MIME_TYPE, mimeType) }, hash?.let { listOf(HASH, it) }, size?.let { listOf(FILE_SIZE, it) }, + dimensions?.let { listOf(DIMENSION, it) }, blurhash?.let { listOf(BLUR_HASH, it) }, magnetURI?.let { listOf(MAGNET_URI, it) }, torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index ec0d94d49..00e208aa9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -78,53 +79,62 @@ import java.io.File import java.security.MessageDigest abstract class ZoomableContent( - val description: String? = null + val description: String? = null, + val dim: String? = null ) abstract class ZoomableUrlContent( val url: String, description: String? = null, val hash: String? = null, + dim: String? = null, val uri: String? = null -) : ZoomableContent(description) +) : ZoomableContent(description, dim) class ZoomableUrlImage( url: String, description: String? = null, hash: String? = null, val bluehash: String? = null, + dim: String? = null, uri: String? = null -) : ZoomableUrlContent(url, description, hash, uri) +) : ZoomableUrlContent(url, description, hash, dim, uri) class ZoomableUrlVideo( url: String, description: String? = null, hash: String? = null, + dim: String? = null, uri: String? = null -) : ZoomableUrlContent(url, description, hash, uri) +) : ZoomableUrlContent(url, description, hash, dim, uri) abstract class ZoomablePreloadedContent( + val localFile: File, description: String? = null, + val mimeType: String? = null, val isVerified: Boolean? = null, + dim: String? = null, val uri: String -) : ZoomableContent(description) +) : ZoomableContent(description, dim) -class ZoomableBitmapImage( - val localFile: File, - val mimeType: String? = null, +class ZoomableLocalImage( + localFile: File, + mimeType: String? = null, description: String? = null, - val bluehash: String? = null, + val blurhash: String? = null, + dim: String? = null, isVerified: Boolean? = null, uri: String -) : ZoomablePreloadedContent(description, isVerified, uri) +) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) -class ZoomableBytesVideo( - val localFile: File, - val mimeType: String? = null, +class ZoomableLocalVideo( + localFile: File, + mimeType: String? = null, description: String? = null, + dim: String? = null, isVerified: Boolean? = null, uri: String -) : ZoomablePreloadedContent(description, isVerified, uri) +) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) fun figureOutMimeType(fullUrl: String): ZoomableContent { val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() @@ -144,45 +154,12 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent { @OptIn(ExperimentalFoundationApi::class) fun ZoomableContentView(content: ZoomableContent, images: List = listOf(content)) { val clipboardManager = LocalClipboardManager.current - val scope = rememberCoroutineScope() - val context = LocalContext.current // store the dialog open or close state var dialogOpen by remember { mutableStateOf(false) } - // store the dialog open or close state - var imageState by remember { - mutableStateOf(null) - } - - var verifiedHash by remember { - mutableStateOf(null) - } - - if (content is ZoomableUrlContent) { - LaunchedEffect(key1 = content.url, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = verifyHash(content, context) - } - } - } - } else if (content is ZoomableBitmapImage) { - LaunchedEffect(key1 = content.localFile, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = content.isVerified - } - } - } - } else if (content is ZoomableBytesVideo) { - LaunchedEffect(key1 = content.localFile, key2 = imageState) { - verifiedHash = content.isVerified - } - } - var mainImageModifier = Modifier .fillMaxWidth() .clip(shape = RoundedCornerShape(15.dp)) @@ -192,17 +169,17 @@ fun ZoomableContentView(content: ZoomableContent, images: List RoundedCornerShape(15.dp) ) + val ratio = aspectRatio(content.dim) + if (ratio != null) { + mainImageModifier = mainImageModifier.aspectRatio(ratio) + } + if (content is ZoomableUrlContent) { mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) } ) - } else if (content is ZoomableBitmapImage) { - mainImageModifier = mainImageModifier.combinedClickable( - onClick = { dialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) } - ) - } else if (content is ZoomableBytesVideo) { + } else if (content is ZoomablePreloadedContent) { mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) } @@ -213,71 +190,127 @@ fun ZoomableContentView(content: ZoomableContent, images: List } } - if (content is ZoomableUrlImage) { - Box(contentAlignment = Alignment.Center) { - AsyncImage( - model = content.url, - contentDescription = content.description, - contentScale = ContentScale.FillWidth, - modifier = mainImageModifier, - onLoading = { - imageState = it - }, - onSuccess = { - imageState = it - } - ) + when (content) { + is ZoomableUrlImage -> UrlImageView(content, mainImageModifier) + is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true } + is ZoomableLocalImage -> LocalImageView(content, mainImageModifier) + is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true } + } - if (imageState is AsyncImagePainter.State.Success) { - HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) - } + if (dialogOpen) { + ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }) + } +} - AnimatedVisibility( - visible = imageState !is AsyncImagePainter.State.Success, - exit = fadeOut(animationSpec = tween(200)) - ) { - if (content.bluehash != null) { - DisplayBlueHash(content, mainImageModifier) - } else { - DisplayUrlWithLoadingSymbol(content) - } - } - } - } else if (content is ZoomableUrlVideo) { - VideoView(content.url, content.description) { dialogOpen = true } - } else if (content is ZoomableBitmapImage) { - Box() { - AsyncImage( - model = content.localFile, - contentDescription = content.description, - contentScale = ContentScale.FillWidth, - modifier = mainImageModifier, - onLoading = { - imageState = it - }, - onSuccess = { - imageState = it - } - ) +@Composable +private fun LocalImageView( + content: ZoomableLocalImage, + mainImageModifier: Modifier +) { + // store the dialog open or close state + var imageState by remember { + mutableStateOf(null) + } - if (imageState is AsyncImagePainter.State.Success) { - HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + Box(contentAlignment = Alignment.Center) { + AsyncImage( + model = content.localFile, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = mainImageModifier, + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it } + ) + + if (imageState is AsyncImagePainter.State.Success) { + HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd)) } - if (imageState !is AsyncImagePainter.State.Success) { + AnimatedVisibility( + visible = imageState !is AsyncImagePainter.State.Success, + exit = fadeOut(animationSpec = tween(200)) + ) { + if (content.blurhash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } + } +} + +@Composable +private fun UrlImageView( + content: ZoomableUrlImage, + mainImageModifier: Modifier +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + // store the dialog open or close state + var imageState by remember { + mutableStateOf(null) + } + + var verifiedHash by remember { + mutableStateOf(null) + } + + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + + Box(contentAlignment = Alignment.Center) { + AsyncImage( + model = content.url, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = mainImageModifier, + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState is AsyncImagePainter.State.Success) { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + + AnimatedVisibility( + visible = imageState !is AsyncImagePainter.State.Success, + exit = fadeOut(animationSpec = tween(200)) + ) { if (content.bluehash != null) { DisplayBlueHash(content, mainImageModifier) } else { DisplayUrlWithLoadingSymbol(content) } } - } else if (content is ZoomableBytesVideo) { - VideoView(content.localFile, content.description) { dialogOpen = true } } +} - if (dialogOpen) { - ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }) +private fun aspectRatio(dim: String?): Float? { + if (dim == null) return null + + val parts = dim.split("x") + if (parts.size != 2) return null + + return try { + val width = parts[0].toFloat() + val height = parts[1].toFloat() + width / height + } catch (e: Exception) { + null } } @@ -341,16 +374,16 @@ private fun DisplayBlueHash( @Composable private fun DisplayBlueHash( - content: ZoomableBitmapImage, + content: ZoomableLocalImage, modifier: Modifier ) { - if (content.bluehash == null) return + if (content.blurhash == null) return val context = LocalContext.current AsyncImage( model = BlurHashRequester.imageRequest( context, - content.bluehash + content.blurhash ), contentDescription = content.description, contentScale = ContentScale.FillWidth, @@ -388,7 +421,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List(null) - } - - var verifiedHash by remember { - mutableStateOf(null) - } - - if (content is ZoomableUrlContent) { - LaunchedEffect(key1 = content.url, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = verifyHash(content, context) - } - } - } - } else if (content is ZoomableBitmapImage) { - LaunchedEffect(key1 = content.localFile, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = content.isVerified - } - } - } - } else if (content is ZoomableBytesVideo) { - LaunchedEffect(key1 = content.localFile, key2 = imageState) { - verifiedHash = content.isVerified - } - } + val mainModifier = Modifier + .fillMaxSize() + .zoomable(rememberZoomState()) if (content is ZoomableUrlImage) { - Box() { - AsyncImage( - model = content.url, - contentDescription = content.description, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxSize() - .zoomable(rememberZoomState()), - onLoading = { - imageState = it - }, - onSuccess = { - imageState = it - } - ) - - if (imageState is AsyncImagePainter.State.Success) { - HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) - } - - AnimatedVisibility( - visible = imageState !is AsyncImagePainter.State.Success, - exit = fadeOut(animationSpec = tween(200)) - ) { - if (content.bluehash != null) { - DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth()) - } else { - DisplayUrlWithLoadingSymbol(content) - } - } - } + UrlImageView(content = content, mainImageModifier = mainModifier) } else if (content is ZoomableUrlVideo) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { VideoView(content.url, content.description) } - } else if (content is ZoomableBitmapImage) { - Box() { - AsyncImage( - model = content.localFile, - contentDescription = content.description, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxSize() - .zoomable(rememberZoomState()), - onLoading = { - imageState = it - }, - onSuccess = { - imageState = it - } - ) - - if (imageState !is AsyncImagePainter.State.Success) { - DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth()) - } else { - HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) - } - } - } else if (content is ZoomableBytesVideo) { + } else if (content is ZoomableLocalImage) { + LocalImageView(content = content, mainImageModifier = mainModifier) + } else if (content is ZoomableLocalVideo) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { VideoView(content.localFile, content.description) } @@ -512,7 +464,7 @@ fun RenderImageOrVideo(content: ZoomableContent) { } @OptIn(ExperimentalCoilApi::class) -private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? { +private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? { if (content.hash == null) return null context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> 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 16da10c3e..0c88ee92b 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 @@ -873,13 +873,13 @@ fun FileHeaderDisplay(note: Note) { withContext(Dispatchers.IO) { val blurHash = event.blurhash() val hash = event.hash() + val dimensions = event.dimensions() val description = event.content val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } val uri = "nostr:" + note.toNEvent() content = if (isImage) { - ZoomableUrlImage(fullUrl, description, hash, blurHash, uri) + ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri) } else { ZoomableUrlVideo(fullUrl, description, hash, uri) } @@ -911,14 +911,30 @@ fun FileStorageHeaderDisplay(baseNote: Note) { val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex) val bytes = eventBytes?.decode() val blurHash = eventHeader.blurhash() + val dimensions = eventHeader.dimensions() val description = eventHeader.content val mimeType = eventHeader.mimeType() content = if (mimeType?.startsWith("image") == true) { - ZoomableBitmapImage(localDir, mimeType, description, blurHash, true, uri) + ZoomableLocalImage( + localFile = localDir, + mimeType = mimeType, + description = description, + blurhash = blurHash, + dim = dimensions, + isVerified = true, + uri = uri + ) } else { if (bytes != null) { - ZoomableBytesVideo(localDir, mimeType, description, true, uri) + ZoomableLocalVideo( + localFile = localDir, + mimeType = mimeType, + description = description, + dim = dimensions, + isVerified = true, + uri = uri + ) } else { null } From 89ce6dbab23606946e54af8ca20ca2e9ef0d7f1a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 11:39:29 -0400 Subject: [PATCH 129/164] Forces search types to be used by the search components and reorganizes search --- .../NostrSearchEventOrUserDataSource.kt | 6 +- .../ui/screen/loggedIn/SearchScreen.kt | 108 ++++++++++++------ 2 files changed, 74 insertions(+), 40 deletions(-) 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 a7262a2f2..f2656f949 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES +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 @@ -50,7 +51,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { ) }, TypedFilter( - types = COMMON_FEED_TYPES, + types = setOf(FeedType.SEARCH), filter = JsonFilter( kinds = listOf(MetadataEvent.kind), search = mySearchString, @@ -58,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { ) ), TypedFilter( - types = COMMON_FEED_TYPES, + types = setOf(FeedType.SEARCH), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind), search = mySearchString, limit = 20 ) 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 5337064d8..3a69e0a2d 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 @@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account @@ -55,6 +57,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrGlobalDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.note.ChannelName import com.vitorpamplona.amethyst.ui.note.NoteCompose @@ -127,14 +130,62 @@ fun SearchScreen( } } +class SearchBarViewModel : ViewModel() { + var searchValue by mutableStateOf("") + val searchResults = mutableStateOf>(emptyList()) + val searchResultsNotes = mutableStateOf>(emptyList()) + val searchResultsChannels = mutableStateOf>(emptyList()) + val hashtagResults = mutableStateOf>(emptyList()) + + val isTrailingIconVisible by + derivedStateOf { + searchValue.isNotBlank() + } + + fun updateSearchValue(newValue: String) { + searchValue = newValue + } + + private fun runSearch() { + if (searchValue.isBlank()) { + hashtagResults.value = emptyList() + searchResults.value = emptyList() + searchResultsChannels.value = emptyList() + searchResultsNotes.value = emptyList() + return + } + + hashtagResults.value = findHashtags(searchValue) + searchResults.value = LocalCache.findUsersStartingWith(searchValue) + searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }.reversed() + searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue) + } + + fun clean() { + searchValue = "" + searchResults.value = emptyList() + searchResultsChannels.value = emptyList() + searchResultsNotes.value = emptyList() + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + runSearch() + } + + fun invalidateData() { + bundler.invalidate() + } + + fun isSearching() = searchValue.isNotBlank() +} + @OptIn(FlowPreview::class) @Composable private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) { - var searchValue by remember { mutableStateOf("") } - val searchResults = remember { mutableStateOf>(emptyList()) } - val searchResultsNotes = remember { mutableStateOf>(emptyList()) } - val searchResultsChannels = remember { mutableStateOf>(emptyList()) } - val hashtagResults = remember { mutableStateOf>(emptyList()) } + val searchBarViewModel: SearchBarViewModel = viewModel() + val scope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -143,25 +194,16 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont val dbState = LocalCache.live.observeAsState() val db = dbState.value ?: return - val isTrailingIconVisible by remember { - derivedStateOf { - searchValue.isNotBlank() - } - } - // Create a channel for processing search queries. val searchTextChanges = remember { CoroutineChannel(CoroutineChannel.CONFLATED) } LaunchedEffect(db) { - if (searchValue.length > 1) { - withContext(Dispatchers.IO) { - searchResults.value = LocalCache.findUsersStartingWith(searchValue) - searchResultsNotes.value = - LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() } - .reversed() - searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue) + withContext(Dispatchers.IO) { + if (searchBarViewModel.isSearching()) { + println("Search Active") + searchBarViewModel.invalidateData() } } } @@ -174,15 +216,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont .distinctUntilChanged() .debounce(300) .collectLatest { - hashtagResults.value = findHashtags(it) - - if (it.length >= 4) { + if (it.length >= 2) { onlineSearch.search(it.trim()) } - searchResults.value = LocalCache.findUsersStartingWith(it) - searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed() - searchResultsChannels.value = LocalCache.findChannelsStartingWith(it) + searchBarViewModel.invalidateData() // makes sure to show the top of the search scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) } @@ -205,9 +243,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont verticalAlignment = Alignment.CenterVertically ) { TextField( - value = searchValue, + value = searchBarViewModel.searchValue, onValueChange = { - searchValue = it + searchBarViewModel.updateSearchValue(it) scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } @@ -234,14 +272,10 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont ) }, trailingIcon = { - if (isTrailingIconVisible) { + if (searchBarViewModel.isTrailingIconVisible) { IconButton( onClick = { - searchValue = "" - searchResults.value = emptyList() - searchResultsChannels.value = emptyList() - searchResultsNotes.value = emptyList() - + searchBarViewModel.clean() onlineSearch.clear() } ) { @@ -260,7 +294,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont ) } - if (searchValue.isNotBlank()) { + if (searchBarViewModel.isSearching()) { LazyColumn( modifier = Modifier.fillMaxHeight(), contentPadding = PaddingValues( @@ -269,17 +303,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont ), state = listState ) { - itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item -> + itemsIndexed(searchBarViewModel.hashtagResults.value, key = { _, item -> "#" + item }) { _, item -> HashtagLine(item) { navController.navigate("Hashtag/$item") } } - itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item -> + itemsIndexed(searchBarViewModel.searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item -> UserCompose(item, accountViewModel = accountViewModel, navController = navController) } - itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item -> + itemsIndexed(searchBarViewModel.searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item -> ChannelName( channelIdHex = item.idHex, channelPicture = item.profilePicture(), @@ -296,7 +330,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont ) } - itemsIndexed(searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item -> + itemsIndexed(searchBarViewModel.searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item -> NoteCompose(item, accountViewModel = accountViewModel, navController = navController) } } From 6d2db65c12510c3d4575ff6378353b381ddbdf30 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 11:48:38 -0400 Subject: [PATCH 130/164] Basic Mp3 Support --- .../com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt | 2 +- .../java/com/vitorpamplona/amethyst/ui/components/VideoView.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 f833be657..31d03d4a5 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 @@ -51,7 +51,7 @@ import java.net.URL import java.util.regex.Pattern val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") -val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov") +val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3") // Group 1 = url, group 4 additional chars val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)") 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 829fe4fb9..d2cf24815 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 @@ -8,6 +8,7 @@ import android.view.ViewGroup import android.widget.FrameLayout import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -125,6 +126,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - AndroidView( modifier = Modifier .fillMaxWidth() + .defaultMinSize(minHeight = 70.dp) .align(Alignment.Center) .onVisibilityChanges { visible -> if (visible) { From 850bc15f4f9d59790d53d4840b78f67e8eccf156 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 12:20:05 -0400 Subject: [PATCH 131/164] More visible buttons in the Video feed. --- .../ui/note/ChatroomMessageCompose.kt | 8 +-- .../amethyst/ui/note/ReactionsRow.kt | 54 ++++++++++--------- .../amethyst/ui/screen/FeedView.kt | 15 ++++-- .../amethyst/ui/screen/LnZapFeedView.kt | 19 +++++-- .../amethyst/ui/screen/RelayFeedView.kt | 18 +++++-- .../amethyst/ui/screen/UserFeedView.kt | 19 +++++-- .../ui/screen/loggedIn/ProfileScreen.kt | 16 +++--- .../ui/screen/loggedIn/VideoScreen.kt | 6 +-- 8 files changed, 103 insertions(+), 52 deletions(-) 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 1294cf883..290988501 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 @@ -113,6 +113,8 @@ fun ChatroomMessageCompose( var alignment: Arrangement.Horizontal var shape: Shape + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + if (note.author == accountUser) { backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) alignment = Arrangement.End @@ -333,11 +335,11 @@ fun ChatroomMessageCompose( } Row() { - LikeReaction(baseNote, accountViewModel) + LikeReaction(baseNote, grayTint, accountViewModel) Spacer(modifier = Modifier.width(5.dp)) - ZapReaction(baseNote, accountViewModel) + ZapReaction(baseNote, grayTint, accountViewModel) Spacer(modifier = Modifier.width(5.dp)) - ReplyReaction(baseNote, accountViewModel, showCounter = false) { + ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) { onWantsToReply(baseNote) } } 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 310c2c7df..21c839669 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 @@ -71,6 +71,8 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + var wantsToReplyTo by remember { mutableStateOf(null) } @@ -91,23 +93,23 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll Row(verticalAlignment = CenterVertically) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { - ReplyReaction(baseNote, accountViewModel) { + ReplyReaction(baseNote, grayTint, accountViewModel) { wantsToReplyTo = baseNote } } Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { - BoostReaction(baseNote, accountViewModel) { + BoostReaction(baseNote, grayTint, accountViewModel) { wantsToQuote = baseNote } } Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { - LikeReaction(baseNote, accountViewModel) + LikeReaction(baseNote, grayTint, accountViewModel) } Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { - ZapReaction(baseNote, accountViewModel) + ZapReaction(baseNote, grayTint, accountViewModel) } Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { - ViewCountReaction(baseNote.idHex) + ViewCountReaction(baseNote.idHex, grayTint) } } } @@ -115,6 +117,7 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll @Composable fun ReplyReaction( baseNote: Note, + grayTint: Color, accountViewModel: AccountViewModel, showCounter: Boolean = true, iconSize: Dp = 20.dp, @@ -142,39 +145,33 @@ fun ReplyReaction( } } ) { - ReplyIcon(iconSize) + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Modifier.size(iconSize), + tint = grayTint + ) } if (showCounter) { Text( " ${showCount(replies.size)}", fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + color = grayTint ) } } -@Composable -private fun ReplyIcon(iconSize: Dp = 15.dp) { - Icon( - painter = painterResource(R.drawable.ic_comment), - null, - modifier = Modifier.size(iconSize), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) -} - @Composable public fun BoostReaction( baseNote: Note, + grayTint: Color, accountViewModel: AccountViewModel, iconSize: Dp = 20.dp, onQuotePress: () -> Unit ) { 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() @@ -234,13 +231,14 @@ public fun BoostReaction( Text( " ${showCount(boostedNote?.boosts?.size)}", fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + color = grayTint ) } @Composable fun LikeReaction( baseNote: Note, + grayTint: Color, accountViewModel: AccountViewModel, iconSize: Dp = 20.dp, heartSize: Dp = 16.dp @@ -248,7 +246,6 @@ fun LikeReaction( 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() @@ -292,7 +289,7 @@ fun LikeReaction( Text( " ${showCount(reactedNote.reactions.size)}", fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + color = grayTint ) } @@ -300,6 +297,7 @@ fun LikeReaction( @OptIn(ExperimentalFoundationApi::class) fun ZapReaction( baseNote: Note, + grayTint: Color, accountViewModel: AccountViewModel, textModifier: Modifier = Modifier, iconSize: Dp = 20.dp, @@ -315,7 +313,6 @@ fun ZapReaction( var wantsToZap by remember { mutableStateOf(false) } var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) } - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -466,15 +463,20 @@ fun ZapReaction( Text( showAmount(zapAmount), fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + color = grayTint, modifier = textModifier ) } @Composable -public fun ViewCountReaction(idHex: String, iconSize: Dp = 20.dp, barChartSize: Dp = 19.dp, numberSize: Dp = 24.dp) { +public fun ViewCountReaction( + idHex: String, + grayTint: Color, + iconSize: Dp = 20.dp, + barChartSize: Dp = 19.dp, + numberSize: Dp = 24.dp +) { val uri = LocalUriHandler.current - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) IconButton( modifier = Modifier.size(iconSize), 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 dcf3f2a4f..1b78c427c 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 @@ -42,7 +42,8 @@ fun FeedView( navController: NavController, routeForLastRead: String?, scrollStateKey: String? = null, - scrollToTop: Boolean = false + scrollToTop: Boolean = false, + enablePullRefresh: Boolean = true ) { val feedState by viewModel.feedContent.collectAsState() @@ -50,7 +51,13 @@ fun FeedView( val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - Box(Modifier.pullRefresh(pullRefreshState)) { + val modifier = if (enablePullRefresh) { + Modifier.pullRefresh(pullRefreshState) + } else { + Modifier + } + + Box(modifier) { Column { Crossfade( targetState = feedState, @@ -88,7 +95,9 @@ fun FeedView( } } - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + if (enablePullRefresh) { + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } } } 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 c13e23f53..ab8e591a2 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 @@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @OptIn(ExperimentalMaterialApi::class) @Composable -fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { +fun LnZapFeedView( + viewModel: LnZapFeedViewModel, + accountViewModel: AccountViewModel, + navController: NavController, + enablePullRefresh: Boolean = true +) { val feedState by viewModel.feedContent.collectAsState() var refreshing by remember { mutableStateOf(false) } val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - Box(Modifier.pullRefresh(pullRefreshState)) { + val modifier = if (enablePullRefresh) { + Modifier.pullRefresh(pullRefreshState) + } else { + Modifier + } + + Box(modifier) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { @@ -59,7 +70,9 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo } } - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + if (enablePullRefresh) { + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } } } 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 e24453a3f..cee08ccf8 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 @@ -96,7 +96,11 @@ class RelayFeedViewModel : ViewModel() { @OptIn(ExperimentalMaterialApi::class) @Composable -fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel) { +fun RelayFeedView( + viewModel: RelayFeedViewModel, + accountViewModel: AccountViewModel, + enablePullRefresh: Boolean = true +) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -114,7 +118,13 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - Box(Modifier.pullRefresh(pullRefreshState)) { + val modifier = if (enablePullRefresh) { + Modifier.pullRefresh(pullRefreshState) + } else { + Modifier + } + + Box(modifier) { Column() { val listState = rememberLazyListState() @@ -136,6 +146,8 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo } } - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + if (enablePullRefresh) { + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } } } 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 79da216b4..d2f8078cd 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 @@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @OptIn(ExperimentalMaterialApi::class) @Composable -fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { +fun UserFeedView( + viewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + navController: NavController, + enablePullRefresh: Boolean = true +) { val feedState by viewModel.feedContent.collectAsState() var refreshing by remember { mutableStateOf(false) } val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - Box(Modifier.pullRefresh(pullRefreshState)) { + val modifier = if (enablePullRefresh) { + Modifier.pullRefresh(pullRefreshState) + } else { + Modifier + } + + Box(modifier) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { @@ -59,7 +70,9 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode } } - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + if (enablePullRefresh) { + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } } } 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 b23bb3623..4a9ab3c11 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 @@ -760,7 +760,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavCon Column( modifier = Modifier.padding(vertical = 0.dp) ) { - FeedView(feedViewModel, accountViewModel, navController, null) + FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) } } } @@ -780,7 +780,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, navController: Nav Column( modifier = Modifier.padding(vertical = 0.dp) ) { - FeedView(feedViewModel, accountViewModel, navController, null) + FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) } } } @@ -801,7 +801,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navControll Column( modifier = Modifier.padding(vertical = 0.dp) ) { - FeedView(feedViewModel, accountViewModel, navController, null) + FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) } } } @@ -821,7 +821,7 @@ fun TabFollows(baseUser: User, accountViewModel: AccountViewModel, navController Column( modifier = Modifier.padding(vertical = 0.dp) ) { - UserFeedView(feedViewModel, accountViewModel, navController) + UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false) } } } @@ -840,7 +840,7 @@ fun TabFollowers(baseUser: User, accountViewModel: AccountViewModel, navControll Column( modifier = Modifier.padding(vertical = 0.dp) ) { - UserFeedView(feedViewModel, accountViewModel, navController) + UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false) } } } @@ -859,7 +859,7 @@ fun TabReceivedZaps(baseUser: User, accountViewModel: AccountViewModel, navContr Column( modifier = Modifier.padding(vertical = 0.dp) ) { - LnZapFeedView(feedViewModel, accountViewModel, navController) + LnZapFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false) } } } @@ -878,7 +878,7 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, navController Column( modifier = Modifier.padding(vertical = 0.dp) ) { - FeedView(feedViewModel, accountViewModel, navController, null) + FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) } } } @@ -913,7 +913,7 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) { Column( modifier = Modifier.padding(vertical = 0.dp) ) { - RelayFeedView(feedViewModel, accountViewModel) + RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 401c48435..adba2b7ea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -367,9 +367,9 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navContr BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) { wantsToQuote = baseNote }*/ - LikeReaction(baseNote, accountViewModel, iconSize = 40.dp, heartSize = 35.dp) - ZapReaction(baseNote, accountViewModel, iconSize = 40.dp, animationSize = 35.dp) - ViewCountReaction(baseNote.idHex, iconSize = 40.dp, barChartSize = 39.dp) + LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, heartSize = 35.dp) + ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = 35.dp) + ViewCountReaction(baseNote.idHex, grayTint = MaterialTheme.colors.onBackground, iconSize = 40.dp, barChartSize = 39.dp) } } From 277fc5971e9b0d271a3f3403f56f737bd7589ff7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 12:31:03 -0400 Subject: [PATCH 132/164] Moving Home feed to start first when loading the app. --- app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index fbff99b63..1faebf214 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -35,8 +35,8 @@ object ServiceManager { NostrChatroomListDataSource.account = myAccount // Notification Elements - NostrAccountDataSource.start() NostrHomeDataSource.start() + NostrAccountDataSource.start() NostrChatroomListDataSource.start() // More Info Data Sources From 81b70780a653899e33c7da5009b0f8ebad8f89ca Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 12:42:38 -0400 Subject: [PATCH 133/164] v0.40.4 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2ccec96b1..efaede082 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 137 - versionName "0.40.3" + versionCode 138 + versionName "0.40.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 79782ee8e72385cd85f7ace1d524b0579148f1f4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 14:32:49 -0400 Subject: [PATCH 134/164] Fixes the volume adjusting interfering with other apps. --- .../amethyst/ui/components/VideoView.kt | 33 ++----------------- 1 file changed, 2 insertions(+), 31 deletions(-) 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 d2cf24815..73ac438cf 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 @@ -1,7 +1,5 @@ package com.vitorpamplona.amethyst.ui.components -import android.content.Context -import android.media.AudioManager import android.net.Uri import android.view.View import android.view.ViewGroup @@ -51,11 +49,9 @@ import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.StyledPlayerView -import com.google.android.exoplayer2.util.Util import com.vitorpamplona.amethyst.VideoCache import java.io.File @@ -76,14 +72,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - val context = LocalContext.current val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) - val myAudioManager = - context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - val myAudioAttributes = AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) - .build() - val exoPlayer = remember(videoUri) { val mediaBuilder = MediaItem.Builder().setUri(videoUri) @@ -98,16 +86,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING - setAudioAttributes(myAudioAttributes, true) - if (Util.SDK_INT >= 23) { - myAudioManager.adjustStreamVolume( - C.STREAM_TYPE_MUSIC, - if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE, - 0 - ) - } else { - myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value) - } + volume = 0f if (videoUri.scheme?.startsWith("file") == true) { setMediaItem(media) } else { @@ -157,15 +136,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - MuteButton(muted, Modifier) { muted.value = !muted.value - if (Util.SDK_INT >= 23) { - myAudioManager.adjustStreamVolume( - C.STREAM_TYPE_MUSIC, - if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE, - 0 - ) - } else { - myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value) - } + exoPlayer.volume = if (muted.value) 0f else 1f } } From 0e1420a6267e08d902939788526a5a235cba6d08 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 14:38:09 -0400 Subject: [PATCH 135/164] Adjust volume based on changing global mute variable. --- .../com/vitorpamplona/amethyst/ui/components/VideoView.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 73ac438cf..0e417c267 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 @@ -100,6 +100,10 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - } } + LaunchedEffect(key1 = muted.value) { + exoPlayer.volume = if (muted.value) 0f else 1f + } + DisposableEffect( Box() { AndroidView( @@ -136,7 +140,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - MuteButton(muted, Modifier) { muted.value = !muted.value - exoPlayer.volume = if (muted.value) 0f else 1f } } From 4bbb34e8c96ceeac45ee22210128762a092e0026 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 14:38:18 -0400 Subject: [PATCH 136/164] v0.40.5 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index efaede082..e7c7b873b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 138 - versionName "0.40.4" + versionCode 139 + versionName "0.40.5" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From a71679ef4389137922b3caa3c44424ef17052e08 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 15:44:59 -0400 Subject: [PATCH 137/164] Removing the exposure of the URL from the initial image and video loads. --- .../java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 0c88ee92b..8d704e699 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 @@ -888,7 +888,7 @@ fun FileHeaderDisplay(note: Note) { content?.let { ZoomableContentView(content = it, listOf(it)) - } ?: UrlPreview(fullUrl, "$fullUrl ") + } } @Composable @@ -944,7 +944,7 @@ fun FileStorageHeaderDisplay(baseNote: Note) { content?.let { ZoomableContentView(content = it, listOf(it)) - } ?: BlankNote() + } } @Composable From 0bf80e88ce00b5e1976a7cf87febf6bc8301b631 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 15:45:32 -0400 Subject: [PATCH 138/164] Avoids videos getting out of the current page and leaking into the next page. --- .../java/com/vitorpamplona/amethyst/ui/components/VideoView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0e417c267..f793fe992 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 @@ -127,7 +127,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - ) controllerAutoShow = false hideController() - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT onDialog?.let { innerOnDialog -> setFullscreenButtonClickListener { exoPlayer.pause() From 99c16879c1a43a4b470de3472f95ad9abf393036 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 15:53:15 -0400 Subject: [PATCH 139/164] Uses resizeMode according to context. --- .../com/vitorpamplona/amethyst/ui/components/VideoView.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 f793fe992..c4305a3c6 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 @@ -6,6 +6,7 @@ import android.view.ViewGroup import android.widget.FrameLayout import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -40,6 +41,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isFinite import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri import androidx.lifecycle.Lifecycle @@ -105,7 +107,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - } DisposableEffect( - Box() { + BoxWithConstraints() { AndroidView( modifier = Modifier .fillMaxWidth() @@ -127,7 +129,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - ) controllerAutoShow = false hideController() - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH onDialog?.let { innerOnDialog -> setFullscreenButtonClickListener { exoPlayer.pause() From 797d1bb2b41a06f7a4ab900c636678a4cfdfb777 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 15:53:44 -0400 Subject: [PATCH 140/164] Don't try to draw local image if the file does not exist --- .../ui/components/ZoomableContentView.kt | 94 ++++++++----------- 1 file changed, 41 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 00e208aa9..78f1f2420 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -95,7 +95,7 @@ class ZoomableUrlImage( url: String, description: String? = null, hash: String? = null, - val bluehash: String? = null, + val blurhash: String? = null, dim: String? = null, uri: String? = null ) : ZoomableUrlContent(url, description, hash, dim, uri) @@ -213,33 +213,37 @@ private fun LocalImageView( } Box(contentAlignment = Alignment.Center) { - AsyncImage( - model = content.localFile, - contentDescription = content.description, - contentScale = ContentScale.FillWidth, - modifier = mainImageModifier, - onLoading = { - imageState = it - }, - onSuccess = { - imageState = it - } - ) + if (content.localFile.exists()) { + AsyncImage( + model = content.localFile, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = mainImageModifier, + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + } if (imageState is AsyncImagePainter.State.Success) { HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd)) } - AnimatedVisibility( - visible = imageState !is AsyncImagePainter.State.Success, - exit = fadeOut(animationSpec = tween(200)) - ) { - if (content.blurhash != null) { - DisplayBlueHash(content, mainImageModifier) - } else { - DisplayUrlWithLoadingSymbol(content) + if (content.blurhash != null) { + AnimatedVisibility( + visible = imageState !is AsyncImagePainter.State.Success, + exit = fadeOut(animationSpec = tween(200)) + ) { + DisplayBlurHash(content.blurhash, content.description, mainImageModifier) } } + + if (imageState is AsyncImagePainter.State.Error) { + DisplayUrlWithLoadingSymbol(content) + } } } @@ -286,16 +290,18 @@ private fun UrlImageView( HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) } - AnimatedVisibility( - visible = imageState !is AsyncImagePainter.State.Success, - exit = fadeOut(animationSpec = tween(200)) - ) { - if (content.bluehash != null) { - DisplayBlueHash(content, mainImageModifier) - } else { - DisplayUrlWithLoadingSymbol(content) + if (content.blurhash != null) { + AnimatedVisibility( + visible = imageState !is AsyncImagePainter.State.Success, + exit = fadeOut(animationSpec = tween(200)) + ) { + DisplayBlurHash(content.blurhash, content.description, mainImageModifier) } } + + if (imageState is AsyncImagePainter.State.Error) { + DisplayUrlWithLoadingSymbol(content) + } } } @@ -354,38 +360,20 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { } @Composable -private fun DisplayBlueHash( - content: ZoomableUrlImage, +private fun DisplayBlurHash( + blurhash: String?, + description: String?, modifier: Modifier ) { - if (content.bluehash == null) return + if (blurhash == null) return val context = LocalContext.current AsyncImage( model = BlurHashRequester.imageRequest( context, - content.bluehash + blurhash ), - contentDescription = content.description, - contentScale = ContentScale.FillWidth, - modifier = modifier - ) -} - -@Composable -private fun DisplayBlueHash( - content: ZoomableLocalImage, - modifier: Modifier -) { - if (content.blurhash == null) return - - val context = LocalContext.current - AsyncImage( - model = BlurHashRequester.imageRequest( - context, - content.blurhash - ), - contentDescription = content.description, + contentDescription = description, contentScale = ContentScale.FillWidth, modifier = modifier ) From 61549ebfd3118dc60348423208cb76d23f7289da Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 16:03:04 -0400 Subject: [PATCH 141/164] BugFix for video saving tool --- .../com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 c47da78fb..6514c5683 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 @@ -126,8 +126,14 @@ object ImageSaver { ) } + val masterUri = if (contentType.startsWith("image")) { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } + val uri = - contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + contentResolver.insert(masterUri, contentValues) checkNotNull(uri) { "Can't insert the new content" } From 580cf765775d6a85509cc8d915bff56f831728cd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 16:46:52 -0400 Subject: [PATCH 142/164] BugFix on calculating the Zap amount. --- .../com/vitorpamplona/amethyst/model/Note.kt | 2 +- .../amethyst/service/model/Event.kt | 1 + .../service/model/LnZapPaymentRequestEvent.kt | 52 ++++++++++++++++--- .../amethyst/ui/note/ReactionsRow.kt | 11 ++-- 4 files changed, 49 insertions(+), 17 deletions(-) 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 c74ed7997..aa3ce4ad5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -286,7 +286,7 @@ open class Note(val idHex: String) { response is PayInvoiceSuccessResponse } .associate { - val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey) + val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey) val amount = try { if (lnInvoice == null) { null 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 e25753d0b..3ecf62c9f 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 @@ -208,6 +208,7 @@ open class Event( .registerTypeAdapter(ByteArray::class.java, ByteArraySerializer()) .registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer()) .registerTypeAdapter(Response::class.java, ResponseDeserializer()) + .registerTypeAdapter(Request::class.java, RequestDeserializer()) .create() fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt index ff21118f2..23799afb3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -1,10 +1,15 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils +import java.lang.reflect.Type import java.util.Date class LnZapPaymentRequestEvent( @@ -18,11 +23,15 @@ class LnZapPaymentRequestEvent( fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) - fun lnInvoice(privKey: ByteArray): String? { + fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? { return try { - val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) + val sharedSecret = Utils.getSharedSecret(privKey, pubkey) - return Utils.decrypt(content, sharedSecret) + val jsonText = Utils.decrypt(content, sharedSecret) + + val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java) + + return (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice } catch (e: Exception) { Log.w("BookmarkList", "Error decrypting the message ${e.message}") null @@ -39,7 +48,7 @@ class LnZapPaymentRequestEvent( createdAt: Long = Date().time / 1000 ): LnZapPaymentRequestEvent { val pubKey = Utils.pubkeyCreate(privateKey) - val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice)) + val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice)) val content = Utils.encrypt( serializedRequest, @@ -59,11 +68,38 @@ class LnZapPaymentRequestEvent( // REQUEST OBJECTS -abstract class Request(val method: String, val params: Params) -abstract class Params +abstract class Request(var method: String? = null) // PayInvoice Call +class PayInvoiceParams(var invoice: String? = null) -class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) { - class PayInvoiceParams(val invoice: String) : Params() +class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") { + + companion object { + fun create(bolt11: String): PayInvoiceMethod { + return PayInvoiceMethod(PayInvoiceParams(bolt11)) + } + } } + + +class RequestDeserializer : + JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): Request? { + val jsonObject = json.asJsonObject + val method = jsonObject.get("method")?.asString + + if (method == "pay_invoice") { + return context.deserialize(jsonObject, PayInvoiceMethod::class.java) + } + return null + } + + companion object { + } +} \ No newline at end of file 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 21c839669..cc3591e3e 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 @@ -319,12 +319,15 @@ fun ZapReaction( var zappingProgress by remember { mutableStateOf(0f) } var wasZappedByLoggedInUser by remember { mutableStateOf(false) } + var zapAmount by remember { mutableStateOf(null) } LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) } + + zapAmount = account.calculateZappedAmount(zappedNote) } } @@ -452,14 +455,6 @@ fun ZapReaction( } } - var zapAmount by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = zapsState) { - withContext(Dispatchers.IO) { - zapAmount = account.calculateZappedAmount(zappedNote) - } - } - Text( showAmount(zapAmount), fontSize = 14.sp, From c8f1e1344863eca97fe18f59d166db3eba467fc6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 16:47:02 -0400 Subject: [PATCH 143/164] Formatting --- .../amethyst/service/model/LnZapPaymentRequestEvent.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt index 23799afb3..966ed230d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -82,7 +82,6 @@ class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invo } } - class RequestDeserializer : JsonDeserializer { @Throws(JsonParseException::class) @@ -102,4 +101,4 @@ class RequestDeserializer : companion object { } -} \ No newline at end of file +} From 8641e0fec5b183dbc1a8f44ddd214b7eaa6cb872 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 17:04:06 -0400 Subject: [PATCH 144/164] Only showing the loading url after 200 ms to avoid blinking the text. --- .../ui/components/ZoomableContentView.kt | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 78f1f2420..cff1373a4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -64,15 +64,19 @@ import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.imageLoader +import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.actions.SaveToGallery +import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.theme.Nip05 import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable import java.io.File @@ -232,17 +236,21 @@ private fun LocalImageView( HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd)) } - if (content.blurhash != null) { - AnimatedVisibility( - visible = imageState !is AsyncImagePainter.State.Success, - exit = fadeOut(animationSpec = tween(200)) - ) { + AnimatedVisibility( + visible = imageState !is AsyncImagePainter.State.Success, + exit = fadeOut(animationSpec = tween(200)) + ) { + if (content.blurhash != null) { DisplayBlurHash(content.blurhash, content.description, mainImageModifier) + } else { + FlowRow() { + DisplayUrlWithLoadingSymbol(content) + } } } if (imageState is AsyncImagePainter.State.Error) { - DisplayUrlWithLoadingSymbol(content) + BlankNote() } } } @@ -290,17 +298,21 @@ private fun UrlImageView( HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) } - if (content.blurhash != null) { - AnimatedVisibility( - visible = imageState !is AsyncImagePainter.State.Success, - exit = fadeOut(animationSpec = tween(200)) - ) { + AnimatedVisibility( + visible = imageState !is AsyncImagePainter.State.Success, + exit = fadeOut(animationSpec = tween(200)) + ) { + if (content.blurhash != null) { DisplayBlurHash(content.blurhash, content.description, mainImageModifier) + } else { + FlowRow() { + DisplayUrlWithLoadingSymbol(content) + } } } if (imageState is AsyncImagePainter.State.Error) { - DisplayUrlWithLoadingSymbol(content) + ClickableUrl(urlText = "${content.url} ", url = content.url) } } } @@ -322,6 +334,20 @@ private fun aspectRatio(dim: String?): Float? { @Composable private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { + var cnt by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + delay(200) + cnt = content + } + } + + cnt?.let { DisplayUrlWithLoadingSymbolWait(it) } +} + +@Composable +private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) { if (content is ZoomableUrlContent) { ClickableUrl(urlText = "${content.url} ", url = content.url) } else { From 31740d8855387978f0dbb17fc7186441201a0808 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 17:06:32 -0400 Subject: [PATCH 145/164] Adding Post Not found on NIP95 images that are not found in any relay. --- .../vitorpamplona/amethyst/ui/components/ZoomableContentView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index cff1373a4..3f522c35c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -249,7 +249,7 @@ private fun LocalImageView( } } - if (imageState is AsyncImagePainter.State.Error) { + if (imageState is AsyncImagePainter.State.Error || !content.localFile.exists()) { BlankNote() } } From 7f18bc3a88edf7bace144e8faacef59872c23478 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 17:11:56 -0400 Subject: [PATCH 146/164] 0.40.6 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e7c7b873b..3f5de8711 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 139 - versionName "0.40.5" + versionCode 140 + versionName "0.40.6" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From f8a60203eced402886f040df63623e95bf3c06ea Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 May 2023 17:59:12 -0400 Subject: [PATCH 147/164] Adds NFC support for NIP19 URIs --- app/src/main/AndroidManifest.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b9b88716f..2fc406f2c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + + + + + + + From 7224ad4b05fc3dd79bae51bd8b1d1a0a7d31052e Mon Sep 17 00:00:00 2001 From: Believethehype Date: Wed, 3 May 2023 00:48:01 +0200 Subject: [PATCH 148/164] image upload works to nostr.build --- .../amethyst/ui/actions/ImageUploader.kt | 106 +++++++++++++----- .../amethyst/ui/actions/NewMediaModel.kt | 1 + .../amethyst/ui/actions/NewPostViewModel.kt | 1 + .../ui/actions/NewUserMetadataViewModel.kt | 1 + 4 files changed, 81 insertions(+), 28 deletions(-) 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 be6095797..766056a87 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 @@ -1,14 +1,20 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.ContentResolver +import android.content.Context import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log import android.webkit.MimeTypeMap import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig import okhttp3.* import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.asRequestBody import okio.BufferedSink import okio.source +import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.io.InputStream @@ -21,13 +27,13 @@ object ImageUploader { fun uploadImage( uri: Uri, server: ServersAvailable, + context: Context, contentResolver: ContentResolver, onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit ) { val contentType = contentResolver.getType(uri) val imageInputStream = contentResolver.openInputStream(uri) - checkNotNull(imageInputStream) { "Can't open the image input stream" } @@ -41,10 +47,14 @@ object ImageUploader { ImgurServer() } - uploadImage(imageInputStream, contentType, myServer, server, onSuccess, onError) + val file = getRealPathFromURI(uri, context)?.let { File(it) } // create path from uri + if (file != null) { + uploadImage(file, imageInputStream, contentType, myServer, server, onSuccess, onError) + } } fun uploadImage( + file: File, inputStream: InputStream, contentType: String?, server: FileServer, @@ -52,38 +62,47 @@ object ImageUploader { onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit ) { - var category = "fileToUpload" - if (serverType != ServersAvailable.NOSTR_BUILD) { - category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" - } + val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + val fileName = randomChars() val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = OkHttpClient.Builder().build() - - val requestBody: RequestBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart( - category, - "$fileName.$extension", - object : RequestBody() { - override fun contentType(): MediaType? = - contentType?.toMediaType() - - override fun writeTo(sink: BufferedSink) { - inputStream.source().use(sink::writeAll) - } - } - ) - .build() - + val requestBody: RequestBody val requestBuilder = Request.Builder() - server.clientID()?.let { - requestBuilder.addHeader("Authorization", it) - } if (serverType == ServersAvailable.NOSTR_BUILD) { requestBuilder.addHeader("Content-Type", "multipart/form-data") + requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "fileToUpload", + "$fileName.$extension", + file.asRequestBody(contentType?.toMediaType()) + + ) + .build() + } else { + requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + category, + "$fileName.$extension", + + object : RequestBody() { + override fun contentType(): MediaType? = + contentType?.toMediaType() + + override fun writeTo(sink: BufferedSink) { + inputStream.source().use(sink::writeAll) + } + } + ) + .build() + } + + server.clientID()?.let { + requestBuilder.addHeader("Authorization", it) } requestBuilder @@ -118,6 +137,38 @@ object ImageUploader { } } +fun getRealPathFromURI(uri: Uri, context: Context): String? { + val returnCursor = context.contentResolver.query(uri, null, null, null, null) + val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME) + returnCursor.moveToFirst() + val name = returnCursor.getString(nameIndex) + val file = File(context.filesDir, name) + try { + val inputStream: InputStream? = context.contentResolver.openInputStream(uri) + val outputStream = FileOutputStream(file) + var read = 0 + val maxBufferSize = 1 * 1024 * 1024 + val bytesAvailable: Int = inputStream?.available() ?: 0 + val bufferSize = Math.min(bytesAvailable, maxBufferSize) + val buffers = ByteArray(bufferSize) + while (inputStream?.read(buffers).also { + if (it != null) { + read = it + } + } != -1 + ) { + outputStream.write(buffers, 0, read) + } + Log.e("File Size", "Size " + file.length()) + inputStream?.close() + outputStream.close() + Log.e("File Path", "Path " + file.path) + } catch (e: java.lang.Exception) { + Log.e("Exception", e.message!!) + } + return file.path +} + abstract class FileServer { abstract fun postUrl(contentType: String?): String abstract fun parseUrlFromSucess(body: String): String? @@ -154,9 +205,8 @@ class ImgurServer : FileServer() { class NostrBuildServer : FileServer() { override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php" - override fun parseUrlFromSucess(body: String): String? { - val url = jacksonObjectMapper().readTree(body) + val url = jacksonObjectMapper().readTree(body) // return url.toString() return url.toString().replace("\"", "") } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 880192257..b21629737 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -75,6 +75,7 @@ open class NewMediaModel : ViewModel() { ImageUploader.uploadImage( uri = uri, server = serverToUse, + context = context, contentResolver = contentResolver, onSuccess = { imageUrl, mimeType -> createNIP94Record(imageUrl, mimeType, description) 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 fd38e3031..c5353035c 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 @@ -145,6 +145,7 @@ open class NewPostViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, server = server, + context = context, contentResolver = contentResolver, onSuccess = { imageUrl, mimeType -> if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94) { 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 269f22871..80abdf2cf 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 @@ -171,6 +171,7 @@ class NewUserMetadataViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, server = account.defaultFileServer, + context = context, contentResolver = context.contentResolver, onSuccess = { imageUrl, mimeType -> onUploading(false) From b801aa6bd1bc0b4adc6c0903b0fb51ca69e28944 Mon Sep 17 00:00:00 2001 From: Bardesss <30936735+Bardesss@users.noreply.github.com> Date: Wed, 3 May 2023 08:08:15 +0000 Subject: [PATCH 149/164] Update strings.xml (NL) Updated translation --- app/src/main/res/values-nl/strings.xml | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index b85757a84..ac28ded24 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -280,9 +280,56 @@ "Zoeken naar event %1$s" Een openbaar bericht toevoegen + Een privébericht toevoegen + Een invoice bericht toevoegen Bedankt voor al jullie werk! Maken en toevoegen Poll auteurs kunnen niet stemmen in hun eigen poll. #zappoll + + De afbeelding is onveranderd sinds de post + De afbeelding is veranderd. De auteur heeft de wijziging misschien niet gezien + + Afbeelding toevoegen/string> + Video toevoegen + Document toevoegen + + Voeg toe aan bericht + Beschrijving van de inhoud + Een blauwe boot in een wit zandstrand bij zonsondergang + + Zap type + Zap type voor alle opties + + Publiek + Iedereen kan de transactie en het bericht zien + + Privé + Verzender en ontvanger kunnen elkaar zien en het bericht lezen + + Anoniem + Ontvanger en het publiek weten niet wie de betaling verzond + + Non-Zap + Geen spoor binnen Nostr, alleen in Lightning + + + File server + LnAddress of @User + + imgur.com - minder beveiligd + Upload naar Imgur. Imgur kan uw afbeelding/video op elk moment wijzigen + + nostrimg.com - minder beveiligd + Upload naar NostrImg. NostrImg kan uw afbeelding/video op elk moment wijzigen + + Verifieerbare Imgur (NIP-94) + Beschermt tegen het achteraf wijzigen van de inhoud door Imgur. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet. + + Verifieerbare NostrImg (NIP-94) + Beschermt tegen het achteraf wijzigen van de inhoud door NostrImg. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet. + + Uw relays (NIP-95) + Bestanden worden geüpload naar en gehost door relays. Ze zijn vrij van een vaste url (afhankelijkheid van derden). Zorg ervoor dat u een NIP-95 relay in uw lijst met relays hebt. From 65540c8a95b23d3b4aff2ede7ca2f8746ab43121 Mon Sep 17 00:00:00 2001 From: Bardesss <30936735+Bardesss@users.noreply.github.com> Date: Wed, 3 May 2023 08:13:35 +0000 Subject: [PATCH 150/164] Update strings.xml (NL) Updated translations --- app/src/main/res/values-nl/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ac28ded24..e4760f5af 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -291,7 +291,7 @@ De afbeelding is onveranderd sinds de post De afbeelding is veranderd. De auteur heeft de wijziging misschien niet gezien - Afbeelding toevoegen/string> + Afbeelding toevoegen Video toevoegen Document toevoegen From 4368b345690217550e23210ca3d690aad3737bbd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 May 2023 10:21:52 -0400 Subject: [PATCH 151/164] Fixes tall images bleeding into other posts. --- .../ui/components/ZoomableContentView.kt | 44 +++++++++++++------ .../ui/screen/loggedIn/VideoScreen.kt | 2 +- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 3f522c35c..c9d3ea77e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio @@ -57,6 +58,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isFinite import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -173,11 +175,6 @@ fun ZoomableContentView(content: ZoomableContent, images: List RoundedCornerShape(15.dp) ) - val ratio = aspectRatio(content.dim) - if (ratio != null) { - mainImageModifier = mainImageModifier.aspectRatio(ratio) - } - if (content is ZoomableUrlContent) { mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, @@ -216,13 +213,22 @@ private fun LocalImageView( mutableStateOf(null) } - Box(contentAlignment = Alignment.Center) { + val ratio = aspectRatio(content.dim) + + BoxWithConstraints(contentAlignment = Alignment.Center) { + val myModifier = mainImageModifier.also { + if (ratio != null) { + it.aspectRatio(ratio, maxHeight.isFinite) + } + } + val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth + if (content.localFile.exists()) { AsyncImage( model = content.localFile, contentDescription = content.description, - contentScale = ContentScale.FillWidth, - modifier = mainImageModifier, + contentScale = contentScale, + modifier = myModifier, onLoading = { imageState = it }, @@ -241,7 +247,7 @@ private fun LocalImageView( exit = fadeOut(animationSpec = tween(200)) ) { if (content.blurhash != null) { - DisplayBlurHash(content.blurhash, content.description, mainImageModifier) + DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) } else { FlowRow() { DisplayUrlWithLoadingSymbol(content) @@ -272,6 +278,8 @@ private fun UrlImageView( mutableStateOf(null) } + val ratio = aspectRatio(content.dim) + LaunchedEffect(key1 = content.url, key2 = imageState) { if (imageState is AsyncImagePainter.State.Success) { scope.launch(Dispatchers.IO) { @@ -280,12 +288,19 @@ private fun UrlImageView( } } - Box(contentAlignment = Alignment.Center) { + BoxWithConstraints(contentAlignment = Alignment.Center) { + val myModifier = mainImageModifier.also { + if (ratio != null) { + it.aspectRatio(ratio, maxHeight.isFinite) + } + } + val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth + AsyncImage( model = content.url, contentDescription = content.description, - contentScale = ContentScale.FillWidth, - modifier = mainImageModifier, + contentScale = contentScale, + modifier = myModifier, onLoading = { imageState = it }, @@ -303,7 +318,7 @@ private fun UrlImageView( exit = fadeOut(animationSpec = tween(200)) ) { if (content.blurhash != null) { - DisplayBlurHash(content.blurhash, content.description, mainImageModifier) + DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) } else { FlowRow() { DisplayUrlWithLoadingSymbol(content) @@ -389,6 +404,7 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) { private fun DisplayBlurHash( blurhash: String?, description: String?, + contentScale: ContentScale, modifier: Modifier ) { if (blurhash == null) return @@ -400,7 +416,7 @@ private fun DisplayBlurHash( blurhash ), contentDescription = description, - contentScale = ContentScale.FillWidth, + contentScale = contentScale, modifier = modifier ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index adba2b7ea..6bed8d8b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -237,7 +237,7 @@ private fun RenderVideoOrPictureNote( var moreActionsExpanded by remember { mutableStateOf(false) } Column(Modifier.fillMaxSize(1f)) { - Row(Modifier.weight(1f, true), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(note) } else if (noteEvent is FileStorageHeaderEvent) { From 67cc24c4d92b6ec0151bb7a5051cbc12468571ac Mon Sep 17 00:00:00 2001 From: Believethehype Date: Wed, 3 May 2023 18:13:18 +0200 Subject: [PATCH 152/164] added nostr.build NIP94 --- .../com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt | 2 ++ .../com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt | 2 ++ .../com/vitorpamplona/amethyst/ui/actions/NewPostView.kt | 3 +++ .../vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 5 ++++- 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index b21629737..9827c6bb2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -44,6 +44,8 @@ open class NewMediaModel : ViewModel() { selectedServer = ServersAvailable.IMGUR_NIP_94 } else if (selectedServer == ServersAvailable.NOSTRIMG) { selectedServer = ServersAvailable.NOSTRIMG_NIP_94 + } else if (selectedServer == ServersAvailable.NOSTR_BUILD) { + selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94 } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 477ca586d..c1489f126 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -116,6 +116,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) { val fileServers = listOf( Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), + Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)), Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) ) @@ -192,6 +193,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) { if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 || + postViewModel.selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 || postViewModel.selectedServer == ServersAvailable.NIP95 ) { Row( 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 c008952dd..370bf8728 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 @@ -590,6 +590,7 @@ enum class ServersAvailable { NOSTRIMG, IMGUR_NIP_94, NOSTRIMG_NIP_94, + NOSTR_BUILD_NIP_94, NIP95 } @@ -613,6 +614,7 @@ fun ImageVideoDescription( Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)), Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), + Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)), Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) ) @@ -742,6 +744,7 @@ fun ImageVideoDescription( if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || selectedServer == ServersAvailable.IMGUR_NIP_94 || + selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 || selectedServer == ServersAvailable.NIP95 ) { Row( 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 c5353035c..42590becc 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 @@ -148,7 +148,7 @@ open class NewPostViewModel : ViewModel() { context = context, contentResolver = contentResolver, onSuccess = { imageUrl, mimeType -> - if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94) { + if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94 || server == ServersAvailable.NOSTR_BUILD_NIP_94) { createNIP94Record(imageUrl, mimeType, description) } else { isUploadingImage = false diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f2094740..cbc905e1d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -334,7 +334,7 @@ Uploads to NostrImg. NostrImg can change your image at any time nostr.build- less secure - Uploads to Nostr.build. Nostr.build can change your image at any time + Uploads to nostr.build. Nostr.build can change your image at any time Verifiable Imgur (NIP-94) Protects from Imgur modifying the content afterwards. This is a new NIP: other clients might not see the image @@ -342,6 +342,9 @@ Verifiable NostrImg (NIP-94) Protects from NostrImg modifying the content afterwards. This is a new NIP: other clients might not see the image + Verifiable Nostr.build (NIP-94) + Protects from Nostr.build modifying the content afterwards. This is a new NIP: other clients might not see the image + Your relays (NIP-95) Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list From 1fad3d1c8af4bf5f6f47bb32cbc5438544eed3e8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 May 2023 17:20:10 -0400 Subject: [PATCH 153/164] Testing the removal of the image proxy server. --- .../com/vitorpamplona/amethyst/ui/components/AsyncImageProxy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8e4dbcd33..4d89a4dfd 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 @@ -22,7 +22,7 @@ data class ResizeImage(val url: String?, val size: Dp) { 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 url // "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64" } } From 5179a821b24e6aef41f26ea41f9e81333bb94ca6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 May 2023 18:25:01 -0400 Subject: [PATCH 154/164] Adjustments to the image upload choice to make sure they fit in a usual screen. --- .../amethyst/ui/components/TextSpinner.kt | 3 ++- app/src/main/res/values/strings.xml | 20 +++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index 7a4b3ca16..d55227fe2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @Composable @@ -106,7 +107,7 @@ fun SpinnerSelectionDialog(options: List, explainers: List?, onD modifier = Modifier .fillMaxWidth() ) { - Text(text = it, color = Color.Gray) + Text(text = it, color = Color.Gray, fontSize = 14.sp) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cbc905e1d..a42bec5a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -327,25 +327,25 @@ File Server LnAddress or @User - imgur.com - less secure - Uploads to Imgur. Imgur can change your image/video at any time + imgur.com - trusted + Imgur can modify the file - nostrimg.com - less secure - Uploads to NostrImg. NostrImg can change your image at any time + nostrimg.com - trusted + NostrImg can modify the file - nostr.build- less secure - Uploads to nostr.build. Nostr.build can change your image at any time + nostr.build - trusted + Nostr.build can modify the file Verifiable Imgur (NIP-94) - Protects from Imgur modifying the content afterwards. This is a new NIP: other clients might not see the image + Checks if Imgur modified the file. New NIP: other clients might not see it Verifiable NostrImg (NIP-94) - Protects from NostrImg modifying the content afterwards. This is a new NIP: other clients might not see the image + Checks if NostrImg modified the file. New NIP: other clients might not see it Verifiable Nostr.build (NIP-94) - Protects from Nostr.build modifying the content afterwards. This is a new NIP: other clients might not see the image + Checks if Nostr.build modified the file. New NIP: other clients might not see it Your relays (NIP-95) - Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list + Files are hosted by your relays. New NIP: check if they support From 76283463b0f3d4f6cfa21e30f236a09de0237ede Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 May 2023 18:26:52 -0400 Subject: [PATCH 155/164] v0.41.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3f5de8711..7035c2efd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 140 - versionName "0.40.6" + versionCode 141 + versionName "0.41.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 95cc2ee589f53d29bf1e0f94fa1ce321cd3e0184 Mon Sep 17 00:00:00 2001 From: Believethehype Date: Thu, 4 May 2023 13:29:00 +0200 Subject: [PATCH 156/164] Update HashtagIcon.kt --- .../main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 7439f2f8c..8312f1876 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -14,7 +14,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? { "plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp)) "coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp)) "skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp)) - "grownostr", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp)) + "grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp)) "footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp)) else -> null } From 8c2e89197ec1ffb177fc5450859868967315814a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 May 2023 20:08:36 -0400 Subject: [PATCH 157/164] Adds support for Private and Public Follow Lists --- .../amethyst/LocalPreferences.kt | 8 + .../vitorpamplona/amethyst/ServiceManager.kt | 3 + .../vitorpamplona/amethyst/model/Account.kt | 48 ++++ .../amethyst/model/LocalCache.kt | 14 + .../service/NostrAccountDataSource.kt | 4 +- .../amethyst/service/NostrDataSource.kt | 1 + .../amethyst/service/NostrHomeDataSource.kt | 4 +- .../amethyst/service/NostrVideoDataSource.kt | 45 ++- .../service/model/BookmarkListEvent.kt | 67 +---- .../amethyst/service/model/Event.kt | 1 + .../service/model/GeneralListEvent.kt | 89 ++++++ .../amethyst/service/model/PeopleListEvent.kt | 54 ++++ .../amethyst/ui/components/TextSpinner.kt | 9 +- .../ui/dal/HomeConversationsFeedFilter.kt | 5 +- .../ui/dal/HomeNewThreadFeedFilter.kt | 5 +- .../amethyst/ui/dal/PeopleListFeedFilter.kt | 25 ++ .../amethyst/ui/dal/VideoFeedFilter.kt | 15 +- .../amethyst/ui/navigation/AppTopBar.kt | 259 +++++++++++++----- .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 7 +- .../ui/screen/loggedIn/VideoScreen.kt | 7 +- app/src/main/res/values/strings.xml | 6 + 21 files changed, 517 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 09e7bf954..cb945c8f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -46,6 +46,8 @@ private object PrefKeys { const val ZAP_AMOUNTS = "zapAmounts" const val DEFAULT_ZAPTYPE = "defaultZapType" const val DEFAULT_FILE_SERVER = "defaultFileServer" + const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" + const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" @@ -197,6 +199,8 @@ object LocalPreferences { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices)) putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType)) putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer)) + putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList) + putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList) putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) @@ -217,6 +221,8 @@ object LocalPreferences { val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language + val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) + val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) val zapAmountChoices = gson.fromJson( getString(PrefKeys.ZAP_AMOUNTS, "[]"), @@ -278,6 +284,8 @@ object LocalPreferences { zapAmountChoices, defaultZapType, defaultFileServer, + defaultHomeFollowList, + defaultStoriesFollowList, zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 1faebf214..480097a68 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource +import com.vitorpamplona.amethyst.service.NostrVideoDataSource import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Constants @@ -33,6 +34,7 @@ object ServiceManager { NostrAccountDataSource.account = myAccount NostrHomeDataSource.account = myAccount NostrChatroomListDataSource.account = myAccount + NostrVideoDataSource.account = myAccount // Notification Elements NostrHomeDataSource.start() @@ -61,6 +63,7 @@ object ServiceManager { NostrSingleUserDataSource.stop() NostrThreadDataSource.stop() NostrUserProfileDataSource.stop() + NostrVideoDataSource.stop() Client.disconnect() } 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 7d5952379..5dad0402f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -38,6 +38,9 @@ fun getLanguagesSpokenByUser(): Set { return codedList } +val GLOBAL_FOLLOWS = " Global " +val KIND3_FOLLOWS = " All Follows " + @OptIn(DelicateCoroutinesApi::class) class Account( val loggedIn: Persona, @@ -50,6 +53,8 @@ class Account( var zapAmountChoices: List = listOf(500L, 1000L, 5000L), var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR, + var defaultHomeFollowList: String? = null, + var defaultStoriesFollowList: String? = null, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -729,6 +734,18 @@ class Account( saveable.invalidateData() } + fun changeDefaultHomeFollowList(name: String?) { + defaultHomeFollowList = name + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultStoriesFollowList(name: String?) { + defaultStoriesFollowList = name + live.invalidateData() + saveable.invalidateData() + } + fun changeZapAmounts(newAmounts: List) { zapAmountChoices = newAmounts live.invalidateData() @@ -741,6 +758,37 @@ class Account( saveable.invalidateData() } + fun selectedUsersFollowList(listName: String?): Set? { + if (listName == GLOBAL_FOLLOWS) return null + if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingKeySet() + + val privKey = loggedIn.privKey + + return if (listName != null) { + val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag() + val list = LocalCache.addressables[aTag] + if (list != null) { + val publicHexList = (list.event as? PeopleListEvent)?.bookmarkedPeople() ?: emptySet() + val privateHexList = privKey?.let { + (list.event as? PeopleListEvent)?.privateTaggedUsers(it) + } ?: emptySet() + + (publicHexList + privateHexList).toSet() + } else { + emptySet() + } + } else { + emptySet() + } + } + + fun selectedTagsFollowList(listName: String?): Set? { + if (listName == GLOBAL_FOLLOWS) return null + if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingTagSet() + + return emptySet() + } + fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { if (!isWriteable()) return 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 6dff6da3a..2ecfcf63e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -159,6 +159,20 @@ object LocalCache { } } + fun consume(event: PeopleListEvent) { + 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(note) + } + } + fun formattedDateTime(timestamp: Long): String { return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) 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 3025a742f..45bbb4f1b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -62,9 +62,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { return TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(BookmarkListEvent.kind), + kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind), authors = listOf(account.userProfile().pubkeyHex), - limit = 1 + limit = 100 ) ) } 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 8335a7177..5de8ef2bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -90,6 +90,7 @@ abstract class NostrDataSource(val debugName: String) { is LongTextNoteEvent -> LocalCache.consume(event, relay) is MetadataEvent -> LocalCache.consume(event) is PrivateDmEvent -> LocalCache.consume(event, relay) + is PeopleListEvent -> LocalCache.consume(event) is ReactionEvent -> LocalCache.consume(event) is RecommendRelayEvent -> LocalCache.consume(event) is ReportEvent -> LocalCache.consume(event, relay) 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 5f855bc7c..a4b5e8232 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -45,7 +45,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { } fun createFollowAccountsFilter(): TypedFilter { - val follows = account.followingKeySet() + val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet() val followKeys = follows.map { it.substring(0, 6) @@ -65,7 +65,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { } fun createFollowTagsFilter(): TypedFilter? { - val hashToLoad = account.followingTagSet() + val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet() if (hashToLoad.isEmpty()) return null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index 5ad410390..6dd343e56 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.relays.FeedType @@ -7,17 +8,47 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter object NostrVideoDataSource : NostrDataSource("VideoFeed") { - fun createGlobalFilter() = TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), - limit = 200 + lateinit var account: Account + + fun createContextualFilter(): TypedFilter? { + val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList) + + val followKeys = follows?.map { + it.substring(0, 6) + } + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + authors = followKeys, + kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), + limit = 200 + ) ) - ) + } + + fun createFollowTagsFilter(): TypedFilter? { + val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList) + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), + tags = mapOf( + "t" to hashToLoad.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 100 + ) + ) + } val videoFeedChannel = requestNewChannel() override fun updateChannelFilters() { - videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null } + videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt index 822f12d5b..71f1ad7e6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt @@ -1,9 +1,6 @@ package com.vitorpamplona.amethyst.service.model -import android.util.Log -import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.HexKey -import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date @@ -15,50 +12,7 @@ class BookmarkListEvent( tags: List>, content: String, sig: HexKey -) : 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) - - fun category() = dTag() - fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) } - - fun plainContent(privKey: ByteArray): String? { - return try { - val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) - - return Utils.decrypt(content, sharedSecret) - } catch (e: Exception) { - Log.w("BookmarkList", "Error decrypting the message ${e.message}") - null - } - } - - @Transient - private var privateTagsCache: List>? = null - - fun privateTags(privKey: ByteArray): List>? { - if (privateTagsCache != null) { - return privateTagsCache - } - - privateTagsCache = try { - gson.fromJson(plainContent(privKey), object : TypeToken>>() {}.type) - } catch (e: Throwable) { - Log.w("BookmarkList", "Error parsing the JSON ${e.message}") - null - } - return privateTagsCache - } - - fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) } - fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) } - fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - +) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { companion object { const val kind = 30001 @@ -77,24 +31,7 @@ class BookmarkListEvent( createdAt: Long = Date().time / 1000 ): BookmarkListEvent { val pubKey = Utils.pubkeyCreate(privateKey) - - val privTags = mutableListOf>() - privEvents?.forEach { - privTags.add(listOf("e", it)) - } - privUsers?.forEach { - privTags.add(listOf("p", it)) - } - privAddresses?.forEach { - privTags.add(listOf("a", it.toTag())) - } - val msg = gson.toJson(privTags) - - val content = Utils.encrypt( - msg, - privateKey, - pubKey - ) + val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey) val tags = mutableListOf>() tags.add(listOf("d", name)) 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 3ecf62c9f..0be651a7d 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 @@ -238,6 +238,7 @@ open class Event( LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) + PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt new file mode 100644 index 000000000..3183fe783 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt @@ -0,0 +1,89 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.google.gson.reflect.TypeToken +import com.vitorpamplona.amethyst.model.HexKey +import nostr.postr.Utils + +abstract class GeneralListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: List>, + content: String, + sig: HexKey +) : 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) + + fun category() = dTag() + fun bookmarkedPosts() = taggedEvents() + fun bookmarkedPeople() = taggedUsers() + + fun plainContent(privKey: ByteArray): String? { + return try { + val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) + + return Utils.decrypt(content, sharedSecret) + } catch (e: Exception) { + Log.w("GeneralList", "Error decrypting the message ${e.message}") + null + } + } + + @Transient + private var privateTagsCache: List>? = null + + fun privateTags(privKey: ByteArray): List>? { + if (privateTagsCache != null) { + return privateTagsCache + } + + privateTagsCache = try { + gson.fromJson(plainContent(privKey), object : TypeToken>>() {}.type) + } catch (e: Throwable) { + Log.w("GeneralList", "Error parsing the JSON ${e.message}") + null + } + return privateTagsCache + } + + fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] } + fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] } + fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.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 { + fun createPrivateTags( + privEvents: List? = null, + privUsers: List? = null, + privAddresses: List? = null, + + privateKey: ByteArray, + pubKey: ByteArray + ): String { + val privTags = mutableListOf>() + privEvents?.forEach { + privTags.add(listOf("e", it)) + } + privUsers?.forEach { + privTags.add(listOf("p", it)) + } + privAddresses?.forEach { + privTags.add(listOf("a", it.toTag())) + } + val msg = gson.toJson(privTags) + + return Utils.encrypt( + msg, + privateKey, + pubKey + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt new file mode 100644 index 000000000..583f534b5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt @@ -0,0 +1,54 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class PeopleListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { + companion object { + const val kind = 30000 + + fun create( + name: String = "", + + events: List? = null, + users: List? = null, + addresses: List? = null, + + privEvents: List? = null, + privUsers: List? = null, + privAddresses: List? = null, + + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): PeopleListEvent { + val pubKey = Utils.pubkeyCreate(privateKey) + val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey) + + val tags = mutableListOf>() + tags.add(listOf("d", name)) + + events?.forEach { + tags.add(listOf("e", it)) + } + users?.forEach { + tags.add(listOf("p", it)) + } + addresses?.forEach { + tags.add(listOf("a", it.toTag())) + } + + val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index d55227fe2..40f40f839 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -33,7 +33,14 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @Composable -fun TextSpinner(label: String, placeholder: String, options: List, explainers: List? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) { +fun TextSpinner( + label: String, + placeholder: String, + options: List, + explainers: List? = null, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier +) { val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } var optionsShowing by remember { mutableStateOf(false) } 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 6026e2669..a5cda41b9 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 @@ -18,9 +18,8 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter() { } private fun innerApplyFilter(collection: Collection): Set { - val user = account.userProfile() - val followingKeySet = user.cachedFollowingKeySet() - val followingTagSet = user.cachedFollowingTagSet() + val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet() + val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet() return collection .asSequence() 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 b5f62086e..fcd175020 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 @@ -24,9 +24,8 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { } private fun innerApplyFilter(collection: Collection): Set { - val user = account.userProfile() - val followingKeySet = user.cachedFollowingKeySet() - val followingTagSet = user.cachedFollowingTagSet() + val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet() + val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet() return collection .asSequence() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt new file mode 100644 index 000000000..11263ffc8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt @@ -0,0 +1,25 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.PeopleListEvent + +object PeopleListFeedFilter : FeedFilter() { + lateinit var account: Account + + override fun feed(): List { + val privKey = account.loggedIn.privKey ?: return emptyList() + + val lists = LocalCache.addressables.values + .asSequence() + .filter { + (it.event is PeopleListEvent) + } + .toSet() + + return lists + .sortedBy { it.createdAt() } + .reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index 6ab0901cb..be17bf33a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.* @@ -20,17 +21,17 @@ object VideoFeedFilter : AdditiveFeedFilter() { private fun innerApplyFilter(collection: Collection): Set { val now = System.currentTimeMillis() / 1000 + val isGlobal = account.defaultStoriesFollowList == GLOBAL_FOLLOWS + + val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet() + val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet() return collection .asSequence() - .filter { - it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent - } + .filter { it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent } + .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) } .filter { account.isAcceptable(it) } - .filter { - // Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating. - it.createdAt()!! <= now - } + .filter { it.createdAt()!! <= now } .toSet() } 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 c99cfe66a..91be7bf4f 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 @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation import android.util.Log import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,6 +24,8 @@ import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -41,6 +44,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import coil.Coil import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource @@ -55,6 +61,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource +import com.vitorpamplona.amethyst.service.model.PeopleListEvent import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.ui.actions.NewRelayListView @@ -62,33 +69,58 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { - when (currentRoute(navController)) { + when (currentRoute(navController)?.substringBefore("?")) { // Route.Profile.route -> TopBarWithBackButton(navController) + Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel) + Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel) else -> MainTopBar(scaffoldState, accountViewModel) } } +@Composable +fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { + GenericTopBar(scaffoldState, accountViewModel) { account -> + FollowList(account.defaultStoriesFollowList, true) { listName -> + account.changeDefaultStoriesFollowList(listName) + } + } +} + +@Composable +fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { + GenericTopBar(scaffoldState, accountViewModel) { account -> + FollowList(account.defaultHomeFollowList, false) { listName -> + account.changeDefaultHomeFollowList(listName) + } + } +} + +@Composable +fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { + GenericTopBar(scaffoldState, accountViewModel) { + AmethystIcon() + } +} + @OptIn(coil.annotation.ExperimentalCoilApi::class) @Composable -fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return - val accountUserState by account.userProfile().live().metadata.observeAsState() - val accountUser = accountUserState?.user ?: return - val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() } val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState() val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState() val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - var wantsToEditRelays by remember { mutableStateOf(false) } @@ -115,48 +147,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - IconButton( - onClick = { - Client.allSubscriptions().map { - "$it ${ - Client.getSubscriptionFilters(it) - .joinToString { it.filter.toJson() } - }" - }.forEach { - Log.d("STATE DUMP", it) - } - - NostrAccountDataSource.printCounter() - NostrChannelDataSource.printCounter() - NostrChatroomDataSource.printCounter() - NostrChatroomListDataSource.printCounter() - NostrGlobalDataSource.printCounter() - NostrHashtagDataSource.printCounter() - NostrHomeDataSource.printCounter() - NostrSearchEventOrUserDataSource.printCounter() - NostrSingleChannelDataSource.printCounter() - NostrSingleEventDataSource.printCounter() - NostrSingleUserDataSource.printCounter() - NostrThreadDataSource.printCounter() - NostrUserProfileDataSource.printCounter() - - Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays()) - - val imageLoader = Coil.imageLoader(context) - Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB") - Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB") - - Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size) - Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size) - } - ) { - Icon( - painter = painterResource(R.drawable.amethyst), - null, - modifier = Modifier.size(40.dp), - tint = Color.Unspecified - ) - } + content(account) } Column( @@ -186,23 +177,10 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) } }, navigationIcon = { - IconButton( - onClick = { - coroutineScope.launch { - scaffoldState.drawerState.open() - } - }, - modifier = Modifier - ) { - RobohashAsyncImageProxy( - robot = accountUser.pubkeyHex, - model = ResizeImage(accountUser.profilePicture(), 34.dp), - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(34.dp) - .height(34.dp) - .clip(shape = CircleShape) - ) + LoggedInUserPictureDrawer(account) { + coroutineScope.launch { + scaffoldState.drawerState.open() + } } }, actions = { @@ -223,6 +201,107 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) } } +@Composable +private fun LoggedInUserPictureDrawer( + account: Account, + onClick: () -> Unit +) { + val accountUserState by account.userProfile().live().metadata.observeAsState() + val accountUser = accountUserState?.user ?: return + + IconButton( + onClick = onClick, + modifier = Modifier + ) { + RobohashAsyncImageProxy( + robot = accountUser.pubkeyHex, + model = ResizeImage(accountUser.profilePicture(), 34.dp), + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(34.dp) + .height(34.dp) + .clip(shape = CircleShape) + ) + } +} + +@Composable +fun FollowList(listName: String?, withGlobal: Boolean, onChange: (String?) -> Unit) { + // Notification + val dbState = LocalCache.live.observeAsState() + val db = dbState.value ?: return + + val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows)) + val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global)) + + val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow) + + var followLists by remember { mutableStateOf(defaultOptions) } + val followNames = remember { derivedStateOf { followLists.map { it.second } } } + + LaunchedEffect(key1 = db) { + withContext(Dispatchers.IO) { + followLists = defaultOptions + LocalCache.addressables.mapNotNull { + val event = (it.value.event as? PeopleListEvent) + // Has to have an list + if (event != null && (event.tags.size > 1 || event.content.length > 50)) { + Pair(event.dTag(), event.dTag()) + } else { + null + } + }.sortedBy { it.second } + } + } + + SimpleTextSpinner( + placeholder = followLists.firstOrNull { it.first == listName }?.first ?: KIND3_FOLLOWS, + options = followNames.value, + onSelect = { + onChange(followLists.getOrNull(it)?.first) + } + ) +} + +@Composable +fun SimpleTextSpinner( + placeholder: String, + options: List, + explainers: List? = null, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + var optionsShowing by remember { mutableStateOf(false) } + var currentText by remember { mutableStateOf(placeholder) } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text(currentText) + Box( + modifier = Modifier + .matchParentSize() + .clickable( + interactionSource = interactionSource, + indication = null + ) { + optionsShowing = true + } + ) + } + + if (optionsShowing) { + options.isNotEmpty().also { + SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) { + currentText = options[it] + optionsShowing = false + onSelect(it) + } + } + } +} + @Composable fun TopBarWithBackButton(navController: NavHostController) { Column() { @@ -250,3 +329,51 @@ fun TopBarWithBackButton(navController: NavHostController) { Divider(thickness = 0.25.dp) } } + +@Composable +fun AmethystIcon() { + val context = LocalContext.current + + IconButton( + onClick = { + Client.allSubscriptions().map { + "$it ${ + Client.getSubscriptionFilters(it) + .joinToString { it.filter.toJson() } + }" + }.forEach { + Log.d("STATE DUMP", it) + } + + NostrAccountDataSource.printCounter() + NostrChannelDataSource.printCounter() + NostrChatroomDataSource.printCounter() + NostrChatroomListDataSource.printCounter() + NostrGlobalDataSource.printCounter() + NostrHashtagDataSource.printCounter() + NostrHomeDataSource.printCounter() + NostrSearchEventOrUserDataSource.printCounter() + NostrSingleChannelDataSource.printCounter() + NostrSingleEventDataSource.printCounter() + NostrSingleUserDataSource.printCounter() + NostrThreadDataSource.printCounter() + NostrUserProfileDataSource.printCounter() + + Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays()) + + val imageLoader = Coil.imageLoader(context) + Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB") + Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB") + + Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size) + Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size) + } + ) { + Icon( + painter = painterResource(R.drawable.amethyst), + null, + modifier = Modifier.size(40.dp), + tint = Color.Unspecified + ) + } +} 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 af8adb2c3..5d7c6265c 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 @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -50,9 +51,11 @@ fun HomeScreen( ) { val coroutineScope = rememberCoroutineScope() val account = accountViewModel.accountLiveData.value?.account ?: return - var wantsToAddNip47 by remember { mutableStateOf(nip47) } + var wantsToAddNip47 by remember { mutableStateOf(nip47) } - LaunchedEffect(accountViewModel) { + val accountState = account.live.observeAsState() + + LaunchedEffect(accountViewModel, accountState.value?.account?.defaultHomeFollowList) { HomeNewThreadFeedFilter.account = account HomeConversationsFeedFilter.account = account NostrHomeDataSource.resetFilters() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 6bed8d8b2..4ae9fef50 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -105,10 +105,14 @@ fun VideoScreen( val lifeCycleOwner = LocalLifecycleOwner.current val account = accountViewModel.accountLiveData.value?.account ?: return + val accountState = account.live.observeAsState() + + NostrVideoDataSource.account = account VideoFeedFilter.account = account - LaunchedEffect(accountViewModel) { + LaunchedEffect(accountViewModel, accountState.value?.account?.defaultStoriesFollowList) { VideoFeedFilter.account = account + NostrVideoDataSource.account = account NostrVideoDataSource.resetFilters() videoFeedView.invalidateData() } @@ -118,6 +122,7 @@ fun VideoScreen( if (event == Lifecycle.Event.ON_RESUME) { println("Video Start") VideoFeedFilter.account = account + NostrVideoDataSource.account = account NostrVideoDataSource.start() videoFeedView.invalidateData() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a42bec5a5..143f5b6b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -348,4 +348,10 @@ Your relays (NIP-95) Files are hosted by your relays. New NIP: check if they support + + + Follow List + All Follows + Global + From dea6f0ca2118c5b25279c09ef37cd7f032638a90 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 May 2023 20:10:01 -0400 Subject: [PATCH 158/164] v0.42.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7035c2efd..8286ddce2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 141 - versionName "0.41.0" + versionCode 142 + versionName "0.42.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 5f4886ce52c593bb522a53dfa378cf39a36f3c43 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 May 2023 21:10:09 -0400 Subject: [PATCH 159/164] Fixes migration of follow lists from old accounts --- .../com/vitorpamplona/amethyst/LocalPreferences.kt | 6 ++++-- .../java/com/vitorpamplona/amethyst/model/Account.kt | 12 ++++++------ .../amethyst/ui/actions/NewMediaModel.kt | 2 +- .../amethyst/ui/navigation/AppTopBar.kt | 4 ++-- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index cb945c8f7..3882218b1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -6,6 +6,8 @@ import android.content.SharedPreferences import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.service.model.ContactListEvent @@ -221,8 +223,8 @@ object LocalPreferences { val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language - val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) - val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) + val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS + val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val zapAmountChoices = gson.fromJson( getString(PrefKeys.ZAP_AMOUNTS, "[]"), 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 5dad0402f..ddacd9993 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -53,8 +53,8 @@ class Account( var zapAmountChoices: List = listOf(500L, 1000L, 5000L), var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR, - var defaultHomeFollowList: String? = null, - var defaultStoriesFollowList: String? = null, + var defaultHomeFollowList: String = KIND3_FOLLOWS, + var defaultStoriesFollowList: String = GLOBAL_FOLLOWS, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -408,12 +408,12 @@ class Account( } } - fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair? { + fun createNip95(byteArray: ByteArray, headerInfo: FileHeader): Pair? { if (!isWriteable()) return null val data = FileStorageEvent.create( mimeType = headerInfo.mimeType ?: "", - data = data, + data = byteArray, privateKey = loggedIn.privKey!! ) @@ -734,13 +734,13 @@ class Account( saveable.invalidateData() } - fun changeDefaultHomeFollowList(name: String?) { + fun changeDefaultHomeFollowList(name: String) { defaultHomeFollowList = name live.invalidateData() saveable.invalidateData() } - fun changeDefaultStoriesFollowList(name: String?) { + fun changeDefaultStoriesFollowList(name: String) { defaultStoriesFollowList = name live.invalidateData() saveable.invalidateData() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 9827c6bb2..ac812b2c2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -138,7 +138,7 @@ open class NewMediaModel : ViewModel() { onReady = { uploadingPercentage.value = 0.90f uploadingDescription.value = "Sending" - val note = account?.sendHeader(it) + account?.sendHeader(it) uploadingPercentage.value = 1.00f isUploadingImage = false onceUploaded() 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 91be7bf4f..4415e4648 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 @@ -226,7 +226,7 @@ private fun LoggedInUserPictureDrawer( } @Composable -fun FollowList(listName: String?, withGlobal: Boolean, onChange: (String?) -> Unit) { +fun FollowList(listName: String, withGlobal: Boolean, onChange: (String) -> Unit) { // Notification val dbState = LocalCache.live.observeAsState() val db = dbState.value ?: return @@ -257,7 +257,7 @@ fun FollowList(listName: String?, withGlobal: Boolean, onChange: (String?) -> Un placeholder = followLists.firstOrNull { it.first == listName }?.first ?: KIND3_FOLLOWS, options = followNames.value, onSelect = { - onChange(followLists.getOrNull(it)?.first) + onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS) } ) } From 84f51dc4731fad324974f822bcdd73e85071ff1d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 May 2023 21:14:42 -0400 Subject: [PATCH 160/164] Removing animation from blurhash --- .../ui/components/ZoomableContentView.kt | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index c9d3ea77e..c2139a7f0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -3,9 +3,6 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.util.Log import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -240,12 +237,7 @@ private fun LocalImageView( if (imageState is AsyncImagePainter.State.Success) { HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd)) - } - - AnimatedVisibility( - visible = imageState !is AsyncImagePainter.State.Success, - exit = fadeOut(animationSpec = tween(200)) - ) { + } else { if (content.blurhash != null) { DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) } else { @@ -311,12 +303,7 @@ private fun UrlImageView( if (imageState is AsyncImagePainter.State.Success) { HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) - } - - AnimatedVisibility( - visible = imageState !is AsyncImagePainter.State.Success, - exit = fadeOut(animationSpec = tween(200)) - ) { + } else { if (content.blurhash != null) { DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) } else { From 3ce6ce9951f5a307b5dab34d36eb84fb56862e13 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 May 2023 21:14:55 -0400 Subject: [PATCH 161/164] v0.42.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 8286ddce2..7942e299b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 142 - versionName "0.42.0" + versionCode 143 + versionName "0.42.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 483e7d673c3cf65947a247084a3993ffe82ac927 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 May 2023 21:57:19 -0400 Subject: [PATCH 162/164] Refactoring part of NoteCompose --- .../amethyst/ui/note/NoteCompose.kt | 247 ++++++++++-------- 1 file changed, 135 insertions(+), 112 deletions(-) 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 8d704e699..eff2915bd 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 @@ -46,6 +46,7 @@ import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -113,6 +114,9 @@ fun NoteComposeInner( accountViewModel: AccountViewModel, navController: NavController ) { + val defaultBackgroundColor = MaterialTheme.colors.background + val defaultPrimaryColor = MaterialTheme.colors.primary + val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return val loggedIn = account.userProfile() @@ -174,7 +178,7 @@ fun NoteComposeInner( } else if (noteEvent is FileStorageHeaderEvent) { FileStorageHeaderDisplay(note) } else { - var isNew by remember { mutableStateOf(false) } + var backgroundColor by remember { mutableStateOf(defaultBackgroundColor) } LaunchedEffect(key1 = routeForLastRead) { withContext(Dispatchers.IO) { @@ -182,54 +186,55 @@ fun NoteComposeInner( val lastTime = NotificationCache.load(it) val createdAt = note.createdAt() - if (createdAt != null) { + val isNew = if (createdAt != null) { NotificationCache.markAsRead(it, createdAt) - isNew = createdAt > lastTime + createdAt > lastTime + } else { + false + } + + backgroundColor = if (isNew) { + val newColor = defaultPrimaryColor.copy(0.12f) + if (parentBackgroundColor != null) { + newColor.compositeOver(parentBackgroundColor) + } else { + newColor.compositeOver(defaultBackgroundColor) + } + } else { + parentBackgroundColor ?: defaultBackgroundColor } } } } - val backgroundColor = if (isNew) { - val newColor = MaterialTheme.colors.primary.copy(0.12f) - if (parentBackgroundColor != null) { - newColor.compositeOver(parentBackgroundColor) - } else { - newColor.compositeOver(MaterialTheme.colors.background) - } - } else { - parentBackgroundColor ?: MaterialTheme.colors.background - } - Column( - modifier = modifier - .combinedClickable( - onClick = { - if (noteEvent is ChannelMessageEvent) { - baseChannel?.let { - navController.navigate("Channel/${it.idHex}") - } - } else if (noteEvent is PrivateDmEvent) { - val replyAuthorBase = - (note.event as? PrivateDmEvent) - ?.recipientPubKey() - ?.let { LocalCache.getOrCreateUser(it) } - - var userToComposeOn = note.author!! - - if (replyAuthorBase != null) { - if (note.author == accountViewModel.userProfile()) { - userToComposeOn = replyAuthorBase - } - } - - navController.navigate("Room/${userToComposeOn.pubkeyHex}") - } else { - navController.navigate("Note/${note.idHex}") + modifier = modifier.combinedClickable( + onClick = { + if (noteEvent is ChannelMessageEvent) { + baseChannel?.let { + navController.navigate("Channel/${it.idHex}") } - }, - onLongClick = { popupExpanded = true } - ) + } else if (noteEvent is PrivateDmEvent) { + val replyAuthorBase = + (note.event as? PrivateDmEvent) + ?.recipientPubKey() + ?.let { LocalCache.getOrCreateUser(it) } + + var userToComposeOn = note.author!! + + if (replyAuthorBase != null) { + if (note.author == accountViewModel.userProfile()) { + userToComposeOn = replyAuthorBase + } + } + + navController.navigate("Room/${userToComposeOn.pubkeyHex}") + } else { + navController.navigate("Note/${note.idHex}") + } + }, + onLongClick = { popupExpanded = true } + ) .background(backgroundColor) ) { Row( @@ -237,78 +242,11 @@ fun NoteComposeInner( .padding( start = if (!isBoostedNote) 12.dp else 0.dp, end = if (!isBoostedNote) 12.dp else 0.dp, - top = if (addMarginTop) 10.dp else 0.dp + top = if (addMarginTop && !isBoostedNote) 10.dp else 0.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) - ) { - NoteAuthorPicture(note, navController, loggedIn, 55.dp) - - if (noteEvent is RepostEvent) { - note.replyTo?.lastOrNull()?.let { - Box( - Modifier - .width(30.dp) - .height(30.dp) - .align(Alignment.BottomEnd) - ) { - NoteAuthorPicture( - it, - navController, - loggedIn, - 35.dp, - pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape) - ) - } - } - } - - // boosted picture - if (noteEvent is ChannelMessageEvent && baseChannel != null) { - val channelState by baseChannel.live.observeAsState() - val channel = channelState?.channel - - if (channel != null) { - Box( - Modifier - .width(30.dp) - .height(30.dp) - .align(Alignment.BottomEnd) - ) { - RobohashAsyncImageProxy( - robot = channel.idHex, - model = ResizeImage(channel.profilePicture(), 30.dp), - contentDescription = stringResource(R.string.group_picture), - modifier = Modifier - .width(30.dp) - .height(30.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.background) - .border( - 2.dp, - MaterialTheme.colors.background, - CircleShape - ) - ) - } - } - } - } - - if (noteEvent is RepostEvent) { - baseNote.replyTo?.lastOrNull()?.let { - RelayBadges(it) - } - } else { - RelayBadges(baseNote) - } - } + DrawAuthorImages(baseNote, loggedIn, navController) } Column( @@ -354,7 +292,7 @@ fun NoteComposeInner( } } - if (note.author != null && !makeItShort && !isQuotedNote) { + if (noteEvent !is RepostEvent && note.author != null && !makeItShort && !isQuotedNote) { Row(verticalAlignment = Alignment.CenterVertically) { ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f)) @@ -370,7 +308,7 @@ fun NoteComposeInner( } } - Spacer(modifier = Modifier.height(3.dp)) + Spacer(modifier = Modifier.height(2.dp)) if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) { val replyingDirectlyTo = note.replyTo?.lastOrNull() @@ -596,6 +534,91 @@ fun NoteComposeInner( } } +@Composable +private fun DrawAuthorImages(baseNote: Note, loggedIn: User, navController: NavController) { + val baseChannel = baseNote.channel() + + Column(Modifier.width(55.dp)) { + // Draws the boosted picture outside the boosted card. + Box(modifier = Modifier.width(55.dp), contentAlignment = Alignment.BottomEnd) { + NoteAuthorPicture(baseNote, navController, loggedIn, 55.dp) + + if (baseNote.event is RepostEvent) { + RepostNoteAuthorPicture(baseNote, navController, loggedIn) + } + + if (baseNote.event is ChannelMessageEvent && baseChannel != null) { + ChannelNotePicture(baseChannel) + } + } + + if (baseNote.event is RepostEvent) { + baseNote.replyTo?.lastOrNull()?.let { + RelayBadges(it) + } + } else { + RelayBadges(baseNote) + } + } +} + +@Composable +private fun ChannelNotePicture(baseChannel: Channel) { + val channelState by baseChannel.live.observeAsState() + val channel = channelState?.channel + + if (channel != null) { + Box( + Modifier + .width(30.dp) + .height(30.dp) + ) { + RobohashAsyncImageProxy( + robot = channel.idHex, + model = ResizeImage(channel.profilePicture(), 30.dp), + contentDescription = stringResource(R.string.group_picture), + modifier = Modifier + .width(30.dp) + .height(30.dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.background) + .border( + 2.dp, + MaterialTheme.colors.background, + CircleShape + ) + ) + } + } +} + +@Composable +private fun RepostNoteAuthorPicture( + baseNote: Note, + navController: NavController, + loggedIn: User +) { + baseNote.replyTo?.lastOrNull()?.let { + Box( + Modifier + .width(30.dp) + .height(30.dp) + ) { + NoteAuthorPicture( + it, + navController, + loggedIn, + 35.dp, + pictureModifier = Modifier.border( + 2.dp, + MaterialTheme.colors.background, + CircleShape + ) + ) + } + } +} + @Composable fun DisplayHighlight( highlight: String, From 51432c8c50dce9f53b084f3337a8e8bf7207bc90 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 May 2023 22:34:09 -0400 Subject: [PATCH 163/164] Fixing the toByteArray call --- .../com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt index 3183fe783..dd236c96c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toByteArray import nostr.postr.Utils abstract class GeneralListEvent( From 12ff434a07697056ab98a6f76b4655d932010107 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 May 2023 22:35:08 -0400 Subject: [PATCH 164/164] v0.42.2 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7942e299b..d7de6c61b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 143 - versionName "0.42.1" + versionCode 144 + versionName "0.42.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {