Compare commits

..
47 Commits
Author SHA1 Message Date
Vitor Pamplona d36f6f4a7e v0.72.2 2023-08-04 21:06:14 -04:00
Vitor Pamplona c2b72f1e45 TimeAgo refactoring 2023-08-04 21:04:13 -04:00
Vitor Pamplona 28e6471ade Puts marked as read on chat to an IO thread 2023-08-04 21:03:19 -04:00
Vitor Pamplona c3b8eb5087 Puts an invalidate data on an IO thread 2023-08-04 21:02:45 -04:00
Vitor Pamplona 75779e7ddc simple refactoring 2023-08-04 21:00:07 -04:00
Vitor Pamplona 118bc7b73c Don't invalidate data on channels if it is already invalidating 2023-08-04 20:58:10 -04:00
Vitor Pamplona e6a0fdedd9 Removes unnecessary signing step 2023-08-04 20:57:21 -04:00
Vitor Pamplona 28098fafd7 Don't push to invalidate unless there is an observer. 2023-08-04 20:56:22 -04:00
Vitor Pamplona 0c0e87af21 Moves headers to the top nav bar 2023-08-04 16:50:23 -04:00
Vitor Pamplona 8abfd7149b Removes the need for .0 when on base sats. 2023-08-04 15:24:41 -04:00
Vitor Pamplona 674896cea4 Moves coroutine creation to the viewModel 2023-08-04 15:20:31 -04:00
Vitor Pamplona ec514651fc Makes the New Channel creation scrollable. 2023-08-04 15:18:30 -04:00
Vitor Pamplona 8071d48911 updates github action to support minified versions of the app 2023-08-04 14:24:11 -04:00
Vitor Pamplona 69daa3835b v0.72.1 2023-08-04 13:18:15 -04:00
Vitor Pamplona 57dac276b2 Simple refactoring of the zap author and comment calculator function 2023-08-04 13:11:35 -04:00
Vitor Pamplona 3a84595aac Expanded DiskCache for images 2023-08-04 12:35:42 -04:00
Vitor Pamplona 7b591c84f8 Moves NIP95 cache to internal/secure folders 2023-08-04 12:32:36 -04:00
Vitor Pamplona e8c8167451 Fixes the interference between simplecache and image chaces. 2023-08-04 12:25:12 -04:00
Vitor Pamplona ec93fc53b2 Adds a cache forever tag to robohashes 2023-08-04 12:17:19 -04:00
Vitor Pamplona ba2902b725 Moves URL Previews into suspend functions 2023-08-04 10:47:01 -04:00
Vitor Pamplona e7defa14b9 Adds a little border for Long Form Content 2023-08-04 10:38:10 -04:00
Vitor Pamplona 4fbc3f25b3 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-04 10:25:42 -04:00
Vitor Pamplona 4ae88d5cc8 Fixes an invalid string in values-fr 2023-08-04 10:25:34 -04:00
Vitor PamplonaandGitHub 1aea0720df Merge pull request #540 from believethehype/nostrbuildv2
Nostr.build API v2, NIP98 support
2023-08-04 10:21:14 -04:00
believethehypeandGitHub 846f340f84 Merge branch 'vitorpamplona:main' into nostrbuildv2 2023-08-04 11:56:05 +02:00
Vitor Pamplona cb137205c0 Fixes french translations 2023-08-03 17:16:55 -04:00
Vitor Pamplona f910709e1b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-03 17:05:26 -04:00
Vitor PamplonaandGitHub 1d78dd6b19 Merge pull request #537 from anthony-robin/update-french-translations
[Translations] Update french texts
2023-08-03 17:05:19 -04:00
AnthonyandGitHub 3197be8a75 Remove translatable="false" keys 2023-08-03 22:00:51 +02:00
AnthonyandGitHub 91ed4128fe More french translations tweaks 2023-08-03 21:59:39 +02:00
AnthonyandGitHub f415490fcd Update more french translations 2023-08-03 21:41:58 +02:00
Vitor Pamplona 4bdb5a28d0 - Fixes new lines after images
- Fixes urls with - https://github.com/vitorpamplona/amethyst/issues/536
2023-08-02 18:56:01 -04:00
AnthonyandGitHub 58efe0b3c0 Update french translations 2023-08-02 23:47:28 +02:00
Vitor Pamplona da3cecd324 Displaying a bytecounter for events in memory when the app pauses. 2023-08-02 16:34:18 -04:00
Vitor Pamplona 09b1451dc1 Sets and Maps are 5x more memory intensive than Lists. This should help with memory management. 2023-08-02 16:33:53 -04:00
Vitor Pamplona 9ae31a3429 Reverting the commented galleries 2023-08-02 16:28:17 -04:00
Vitor Pamplona b5258f89c1 Correcting the acceptable filter 2023-08-02 16:27:41 -04:00
Vitor Pamplona fcc67fc4f2 Faster Galleries 2023-08-02 14:32:35 -04:00
Vitor Pamplona 579465ac6c Avoids null model errors that might be delaying some of the rendering. 2023-08-02 13:29:44 -04:00
Vitor Pamplona 6cbee81e72 Moves onCreate intent evaluation to the App Navigation 2023-08-02 13:29:04 -04:00
Believethehype ad683ed444 added nostr.build api v2 / nip98
replaced nostr.build api with v2

Added NIP98 to nostr.build so images get added to premium account galleries, if available.
2023-08-02 04:14:35 +02:00
Vitor Pamplona c53b1571a3 Additional classes to be serialized 2023-08-01 21:56:30 -04:00
Vitor Pamplona 2c19b2ffef Moves Geolocation search to the IO thread 2023-08-01 21:52:18 -04:00
Vitor Pamplona 3694d1b0f5 Avoids using String format. 2023-08-01 21:51:47 -04:00
Vitor Pamplona e7a4746d22 Faster method to generate the hex of a url 2023-08-01 21:51:15 -04:00
Vitor Pamplona 7961f6f065 Removes unnecessary onIntent override 2023-08-01 21:50:49 -04:00
Vitor Pamplona 7c1c500990 Prepares for a minified release. 2023-08-01 21:49:30 -04:00
51 changed files with 1042 additions and 479 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
+5 -5
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 267
versionName "0.72.0"
versionCode 269
versionName "0.72.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -25,9 +25,9 @@ android {
buildTypes {
release {
// TODO: Make sure all of JSON parsers work when activating these.
//minifyEnabled true
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
//signingConfig signingConfigs.debug
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled true
resValue "string", "app_name", "@string/app_name_release"
}
debug {
+40 -25
View File
@@ -5,33 +5,48 @@
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# For the Secp256k1 library
# preserve the line number information for debugging stack traces.
-dontobfuscate
-keepattributes LocalVariableTable
-keepattributes LocalVariableTypeTable
-keepattributes *Annotation*
-keepattributes SourceFile
-keepattributes LineNumberTable
-keepattributes Signature
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
-keepattributes MethodParameters
-keepparameternames
-keep class fr.acinq.secp256k1.jni.** { *; }
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keepdirectories libs
# Keep all names
-keepnames class ** { *; }
# Keep All enums
-keep enum ** { *; }
# preserve access to native classses
-keep class fr.acinq.secp256k1.** { *; }
# GSON parsing
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
# Json parsing
-keep class com.google.gson.reflect.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep public class * implements java.lang.reflect.Type
-keep class com.vitorpamplona.amethyst.lnurl.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
-keep class com.vitorpamplona.amethyst.ui.** { *; }
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Prevent R8 from leaving Data object members always null
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
@@ -668,7 +668,7 @@ class RichTextParserTest {
fun testTextToParse() {
val state = RichTextParser().parseText(textToParse, ImmutableListOfLists())
Assert.assertEquals(
"relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, ⚡41.7M, Nostr-Check.com, MR.Rabbit, Ancap.su, ⚡️satscoinsv@getalby.com, miceliomad@miceliomad.github.io/nostr/, zapper.lol, smies.me, baller.hodl",
"relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl",
state.urlSet.joinToString(", ")
)
@@ -806,134 +806,101 @@ class RichTextParserTest {
"HashTag(#bitcoin,)",
"RegularText(1.7K,)",
"RegularText(109)",
"RegularText()",
"HashTag(#concussion,)",
"RegularText(1.1K,)",
"RegularText(25)",
"RegularText()",
"HashTag(#press,)",
"RegularText(0.9K,)",
"RegularText(65)",
"RegularText()",
"HashTag(#france,)",
"RegularText(492,)",
"RegularText(46)",
"RegularText()",
"HashTag(#presse,)",
"RegularText(480,)",
"RegularText(42)",
"RegularText()",
"HashTag(#covid19,)",
"RegularText(465,)",
"RegularText(65)",
"RegularText()",
"HashTag(#nostr,)",
"RegularText(414,)",
"RegularText(109)",
"RegularText()",
"HashTag(#zapathon,)",
"RegularText(386,)",
"RegularText(76)",
"RegularText()",
"HashTag(#rssfeed,)",
"RegularText(309,)",
"RegularText(53)",
"RegularText()",
"HashTag(#btc,)",
"RegularText(299,)",
"RegularText(109)",
"RegularText()",
"HashTag(#news,)",
"RegularText(294,)",
"RegularText(91)",
"RegularText()",
"HashTag(#zap,)",
"RegularText(283,)",
"RegularText(109)",
"RegularText()",
"HashTag(#linux,)",
"RegularText(253,)",
"RegularText(88)",
"RegularText()",
"HashTag(#respond,)",
"RegularText(246,)",
"RegularText(90)",
"RegularText()",
"HashTag(#kompost,)",
"RegularText(240,)",
"RegularText(31)",
"RegularText()",
"HashTag(#plebchain,)",
"RegularText(236,)",
"RegularText(109)",
"RegularText()",
"HashTag(#gardenaward,)",
"RegularText(236,)",
"RegularText(31)",
"RegularText()",
"HashTag(#start,)",
"RegularText(236,)",
"RegularText(31)",
"RegularText()",
"HashTag(#unicef,)",
"RegularText(233,)",
"RegularText(32)",
"RegularText()",
"HashTag(#coronavirus,)",
"RegularText(233,)",
"RegularText(33)",
"RegularText()",
"HashTag(#bew,)",
"RegularText(229,)",
"RegularText(31)",
"RegularText()",
"HashTag(#balkon,)",
"RegularText(229,)",
"RegularText(31)",
"RegularText()",
"HashTag(#terrasse,)",
"RegularText(229,)",
"RegularText(31)",
"RegularText()",
"HashTag(#braininjuryawareness,)",
"RegularText(229,)",
"RegularText(24)",
"RegularText()",
"HashTag(#garten,)",
"RegularText(220,)",
"RegularText(21)",
"RegularText()",
"HashTag(#smart,)",
"RegularText(220,)",
"RegularText(21)",
"RegularText()",
"HashTag(#nsfw,)",
"RegularText(211,)",
"RegularText(85)",
"RegularText()",
"HashTag(#protoncalendar,)",
"RegularText(206,)",
"RegularText(31)",
"RegularText()",
"HashTag(#stacksats,)",
"RegularText(195,)",
"RegularText(99)",
"RegularText()",
"HashTag(#nokyc,)",
"RegularText(179,)",
"RegularText(98)",
"RegularText()",
"RegularText()",
"RegularText(Emoji sentiment today)",
"RegularText()",
"RegularText(⚡ (1.6K) 👉 (1.4K) 🇪🇺 (1.2K) 🫂 (1.2K) 🇺🇸 (1.1K) 💜 (875) 🧠 (858) 😂 (830) 🔥 (690) 🤣 (566) 🤙 (525) ☕ (444) 👇 (443) 🙌🏻 (425) ☀ (307) 😎 (305) 🥳 (301) 🤔 (276) 🌻 (270) 🧡 (270) 🥇 (269) 🗓 (269) 🙏 (268) 🏆 (267) 🌱 (264) 📰 (230) 🏉 (221) 😭 (220) 💰 (219) 🔗 (209) 👀 (201) 😅 (199) ✨ (193) 🇷🇺 (182) 💪 (167) ✅ (164) 💤 (163) 🐶 (151) 🇨🇭 (141) 📝 (137) 😁 (136) 🌞 (136) 🐾 (136) ❤ (132) 💻 (126) 🚀 (125) 👍 (125) 🇧🇷 (125) 😊 (121) 📚 (120) ➡ (120) 👏 (118) 🎉 (117) 🎮 (115) 🤷 (113) 👋 (112) 💃 (108) 🕺🏻 (106) 💡 (104) 🚨 (99) 😆 (97) 💯 (95) ⚠ (92) 📢 (92) 🤗 (89) 😴 (87) 🔁 (83) 🐰 (81) 😀 (79) 🎟 (78) ⛏ (78) 🐦 (76) 💸 (76) ✌🏻 (75) 🤍 (73) 🇬🇧 (73) 🌽 (70) 🤡 (69) 🤮 (69) ❗ (66) 🤝 (65) 😉 (65) 🙇 (65) 🍻 (64) 🌍 (64) 💕 (63) 🌸 (62) 💬 (61) ☺ (61) 🇦🇷 (59) 🇮🇩 (57) 😳 (57) 😄 (57) 🎶 (57) 🥷🏻 (56) 🎵 (56) 😃 (56) 🔍 (55) 💥 (55) 🎲 (54) ✍ (54) 🕒 (53) ⬇ (53) 💙 (51) 🔒 (50) 📈 (50) 🪙 (50) 🌧 (50) 🥰 (50) 🕸 (50) 🌐 (50) 💭 (49) 🌙 (49) 😍 (49) 📱 (48) 🌟 (48) 🤩 (48) 💔 (47) 🔌 (47) 😋 (47) 🎖 (47) 🍣 (46) 📷 (46) 💼 (45) ⭐ (45) 🥔 (45) 🥺 (45) 👌 (44) 👷🏼 (43) 😱 (43) 📅 (43) 🤖 (43) 📸 (42) 📊 (42) 🦑 (40) 💵 (40) 🤦 (39) ❣ (38) 💎 (38) 🖤 (38) 📺 (37) 🇵🇱 (37) 🇯🇵 (36) 🔧 (36) 🤘 (36) 💖 (36) ‼ (35) 😢 (35) 😺 (34) 🔊 (34) 😏 (34) 🇸🇰 (34) 🏃 (34) 👩‍👧 (34) ⏰ (33) 👨‍💻 (33) 👑 (33) 👥 (32) 🖥 (32) 💨 (32) 💗 (31) 🇲🇽 (31) 📖 (31) 🚫 (31) 👊🏻 (31) 😡 (31) 🌎 (31) 👁 (30) 🗞 (30) 🍀 (30) 🍽 (29) 🐸 (29) 🥚 (29) 💩 (29) ✊🏾 (29) 😮 (29) 🌡 (29) 🙃 (28) 🔔 (28) 🇻🇪 (28) 💦 (28) 🎯 (28) 🎨 (28) 🍛 (28) 🖼 (27) ☝🏻 (27) 🛑 (27) 🙄 (27) 🧑🏻‍🤝‍🧑🏽 (27) 🌈 (27) 🥂 (26) 🇫🇮 (26) 🎥 (26) 😬 (26) 🥲 (25) 🦾 (24) 🤜 (24) 🙂 (24) 🖕 (24) 😩 (24) )",
"RegularText()",
"RegularText(Zap economy)",
"RegularText()",
"Link(⚡41.7M)",
"RegularText(sats)",
"RegularText((₿0.417))",
"RegularText()",
"RegularText(⚡41.7M sats (₿0.417) )",
"RegularText(1,816 zappers & 920 zapped (unique pubkeys))",
"RegularText(🌩️ 33,248 zaps, 1,253 sats per zap (avg))",
"RegularText()",
@@ -2248,7 +2215,7 @@ class RichTextParserTest {
"HashTag(#214)",
"RegularText(2%)",
"RegularText(Satscoinsv,)",
"Link(⚡️satscoinsv@getalby.com)",
"RegularText(⚡️satscoinsv@getalby.com)",
"RegularText(-)",
"RegularText(80db64657ea0358c5332c5cca01565eeddd4b8799688b1c46d3cb2d7c966671f)",
"HashTag(#215)",
@@ -2458,7 +2425,7 @@ class RichTextParserTest {
"HashTag(#249)",
"RegularText(2%)",
"RegularText(micmad,)",
"Link(miceliomad@miceliomad.github.io/nostr/)",
"RegularText(miceliomad@miceliomad.github.io/nostr/)",
"RegularText(-)",
"RegularText(cd806edcf8ff40ea94fa574ea9cd97da16e5beb2b85aac6e1d648b8388504343)",
"HashTag(#250)",
@@ -3805,7 +3772,7 @@ class RichTextParserTest {
"HashTag(#471)",
"RegularText(1%)",
"RegularText(leonwankum,)",
"SchemelessUrl(@leonawankum@BitcoinNostr.com)",
"RegularText(@leonawankum@BitcoinNostr.com)",
"RegularText()",
"RegularText(-)",
"RegularText(652d58acafa105af8475c0fe8029a52e7ddbc337b2bd9c98bb17a111dc4cde60)",
@@ -4098,7 +4065,6 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
"RegularText(here:)",
"Link(https://lnshort.it/live-stream-embeds/)",
"RegularText()",
"RegularText()",
"Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)"
)
@@ -4110,6 +4076,57 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
}
}
@Test
fun testNewLineAfterImage() {
val text = "Thats it ! Thats the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg "
val state = RichTextParser().parseText(text, ImmutableListOfLists())
printStateForDebug(state)
val expectedResult = listOf<String>(
"RegularText(Thats)",
"RegularText(it)",
"RegularText(!)",
"RegularText(Thats)",
"RegularText(the)",
"HashTag(#note)",
"Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)"
)
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals(
expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
)
}
}
@Test
fun testSapceAfterImage() {
val text = "Thats it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg Thats the #note"
val state = RichTextParser().parseText(text, ImmutableListOfLists())
printStateForDebug(state)
val expectedResult = listOf<String>(
"RegularText(Thats)",
"RegularText(it!)",
"Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)",
"RegularText(Thats)",
"RegularText(the)",
"HashTag(#note)"
)
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals(
expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
)
}
}
private fun printStateForDebug(state: RichTextViewerState) {
state.paragraphs.forEachIndexed { index, paragraph ->
paragraph.words.forEach { seg ->
@@ -10,7 +10,9 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.parseConnectivityType
@@ -22,8 +24,6 @@ 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.service.toNpub
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import fr.acinq.secp256k1.Hex
import java.io.File
import java.util.Locale
@@ -8,6 +8,8 @@ import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.util.DebugLogger
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
@@ -29,6 +31,7 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import java.io.File
object ServiceManager {
private var account: Account? = null
@@ -55,7 +58,8 @@ object ServiceManager {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
}.logger(DebugLogger())
.diskCache { SingletonDiskCache.get(context.applicationContext) }
.okHttpClient { HttpClient.getHttpClient() }
.respectCacheHeaders(false)
.build()
@@ -121,3 +125,28 @@ object ServiceManager {
}
}
}
object SingletonDiskCache {
private const val DIRECTORY = "image_cache"
private var instance: DiskCache? = null
@Synchronized
fun get(context: Context): DiskCache {
return instance ?: run {
// Create the singleton disk cache instance.
DiskCache.Builder()
.directory(context.safeCacheDir.resolve(DIRECTORY))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.build()
.also { instance = it }
}
}
}
internal val Context.safeCacheDir: File
get() {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
}
@@ -8,6 +8,7 @@ import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import okhttp3.OkHttpClient
import java.io.File
@UnstableApi object VideoCache {
@@ -25,7 +26,7 @@ import okhttp3.OkHttpClient
exoDatabaseProvider = StandaloneDatabaseProvider(context)
simpleCache = SimpleCache(
context.cacheDir,
File(context.cacheDir, "exoplayer"),
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider
)
@@ -18,9 +18,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 com.vitorpamplona.amethyst.ui.note.combineWith
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
@@ -1516,9 +1514,9 @@ class Account(
private fun isAcceptableDirect(note: Note): Boolean {
if (!warnAboutPostsWithReports) {
return note.reportsBy(userProfile()).isEmpty()
return !note.hasReportsBy(userProfile())
}
return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
return !note.hasReportsBy(userProfile()) && // if user has not reported this post
note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
}
@@ -1086,7 +1086,7 @@ object LocalCache {
}
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
val cachePath = File(Amethyst.instance.applicationContext.cacheDir, "NIP95")
cachePath.mkdirs()
val file = File(cachePath, event.id)
if (!file.exists()) {
@@ -0,0 +1,3 @@
package com.vitorpamplona.amethyst.model
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
@@ -51,20 +51,20 @@ open class Note(val idHex: String) {
var replyTo: List<Note>? = null
// These fields are updated every time an event related to this note is received.
var replies = setOf<Note>()
var replies = listOf<Note>()
private set
var reactions = mapOf<String, Set<Note>>()
var reactions = mapOf<String, List<Note>>()
private set
var boosts = setOf<Note>()
var boosts = listOf<Note>()
private set
var reports = mapOf<User, Set<Note>>()
var reports = mapOf<User, List<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
var relays = setOf<String>()
var relays = listOf<String>()
private set
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
@@ -151,15 +151,20 @@ open class Note(val idHex: String) {
}
fun removeReply(note: Note) {
replies = replies - note
liveSet?.replies?.invalidateData()
}
fun removeBoost(note: Note) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
if (note in replies) {
replies = replies - note
liveSet?.replies?.invalidateData()
}
}
fun removeAllChildNotes(): Set<Note> {
fun removeBoost(note: Note) {
if (note in boosts) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
}
}
fun removeAllChildNotes(): List<Note> {
val toBeRemoved = replies +
reactions.values.flatten() +
boosts +
@@ -169,13 +174,13 @@ open class Note(val idHex: String) {
zapPayments.keys +
zapPayments.values.filterNotNull()
replies = setOf<Note>()
reactions = mapOf<String, Set<Note>>()
boosts = setOf<Note>()
reports = mapOf<User, Set<Note>>()
replies = listOf<Note>()
reactions = mapOf<String, List<Note>>()
boosts = listOf<Note>()
reports = mapOf<User, List<Note>>()
zaps = mapOf<Note, Note?>()
zapPayments = mapOf<Note, Note?>()
relays = setOf<String>()
relays = listOf<String>()
lastReactionsDownloadTime = emptyMap()
liveSet?.replies?.invalidateData()
@@ -193,14 +198,16 @@ open class Note(val idHex: String) {
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
reactions[reaction]?.let {
val newList = it.minus(note)
if (newList.isEmpty()) {
reactions = reactions.minus(reaction)
} else {
reactions = reactions + Pair(reaction, newList)
}
if (note in it) {
val newList = it.minus(note)
if (newList.isEmpty()) {
reactions = reactions.minus(reaction)
} else {
reactions = reactions + Pair(reaction, newList)
}
liveSet?.reactions?.invalidateData()
liveSet?.reactions?.invalidateData()
}
}
}
}
@@ -306,7 +313,7 @@ open class Note(val idHex: String) {
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, setOf(note))
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.reactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
@@ -318,7 +325,7 @@ open class Note(val idHex: String) {
val author = note.author ?: return
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
reports = reports + Pair(author, listOf(note))
liveSet?.reports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
@@ -411,8 +418,8 @@ open class Note(val idHex: String) {
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
fun hasReportsBy(user: User): Boolean {
return reports[user]?.isNotEmpty() ?: false
}
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
@@ -576,9 +583,9 @@ open class Note(val idHex: String) {
}
replyTo = null
replies = emptySet()
replies = emptyList()
reactions = emptyMap()
boosts = emptySet()
boosts = emptyList()
reports = emptyMap()
zaps = emptyMap()
}
@@ -646,6 +653,8 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()
bundler.invalidate() {
@@ -0,0 +1,16 @@
package com.vitorpamplona.amethyst.model
enum class ServersAvailable {
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
NOSTRCHECK_ME,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NOSTRCHECK_ME_NIP_94,
NIP95
}
@@ -2,33 +2,35 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Stable
object UrlCachedPreviewer {
var cache = LruCache<String, UrlPreviewState>(100)
private set
fun previewInfo(url: String, onReady: (UrlPreviewState) -> Unit) {
checkNotInMainThread()
suspend fun previewInfo(
url: String,
onReady: suspend (UrlPreviewState) -> Unit
) = withContext(Dispatchers.IO) {
cache[url]?.let {
onReady(it)
return
return@withContext
}
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
override suspend fun onComplete(urlInfo: UrlInfoItem) = withContext(Dispatchers.IO) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return
return@withContext
}
}
@@ -42,10 +44,10 @@ object UrlCachedPreviewer {
onReady(state)
}
override fun onFailed(throwable: Throwable) {
override suspend fun onFailed(throwable: Throwable) = withContext(Dispatchers.IO) {
cache[url]?.let {
onReady(it)
return
return@withContext
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
@@ -475,6 +475,7 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()
bundler.invalidate() {
@@ -54,7 +54,7 @@ object CachedRichTextParser {
// Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator = try {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]*[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
} catch (e: Exception) {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
}
@@ -119,7 +119,7 @@ class RichTextParser() {
val isRTL = isArabic(paragraph)
val wordList = paragraph.split(' ')
val wordList = paragraph.trimEnd().split(' ')
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
@@ -182,8 +182,8 @@ class RichTextParser() {
} else if (schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
val pattern = "^([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(word)) {
val pattern = "^([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.find(word) != null) {
SchemelessUrlSegment(word, url, additionalChars)
} else {
RegularTextSegment(word)
@@ -63,7 +63,7 @@ class LocationUtil(context: Context) {
}
class ReverseGeoLocationUtil {
fun execute(
suspend fun execute(
location: Location,
context: Context
): String? {
@@ -1,12 +1,10 @@
package com.vitorpamplona.amethyst.ui.note
import android.net.Uri
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
// Rename to the corect nip number when ready.
object Nip47 {
fun parse(uri: String): Nip47URI {
@@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.bytesUsedInMemory
import fr.acinq.secp256k1.Hex
import java.lang.reflect.Type
import java.math.BigDecimal
@@ -24,6 +25,16 @@ open class Event(
val content: String,
val sig: HexKey
) : EventInterface {
override fun countMemory(): Long {
return 12L +
id.bytesUsedInMemory() +
pubKey.bytesUsedInMemory() +
tags.sumOf { it.sumOf { it.bytesUsedInMemory() } } +
content.bytesUsedInMemory() +
sig.bytesUsedInMemory()
}
override fun id(): HexKey = id
override fun pubKey(): HexKey = pubKey
@@ -6,6 +6,8 @@ import java.math.BigDecimal
@Immutable
interface EventInterface {
fun countMemory(): Long
fun id(): HexKey
fun pubKey(): HexKey
@@ -1,25 +1,21 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.*
import java.util.*
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
fun fetchUrlPreview(timeOut: Int = 30000) {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
callback?.onFailed(throwable)
}
scope.launch(exceptionHandler) {
suspend fun fetchUrlPreview(timeOut: Int = 30000) = withContext(Dispatchers.IO) {
try {
fetch(timeOut)
} catch (t: Throwable) {
callback?.onFailed(t)
}
}
private suspend fun fetch(timeOut: Int = 30000) {
checkNotInMainThread()
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
@@ -43,7 +39,6 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
}
fun cleanUp() {
scope.cancel()
callback = null
}
}
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service.previews
interface IUrlPreviewCallback {
fun onComplete(urlInfo: UrlInfoItem)
fun onFailed(throwable: Throwable)
suspend fun onComplete(urlInfo: UrlInfoItem)
suspend fun onFailed(throwable: Throwable)
}
@@ -56,9 +56,6 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent?.data?.toString()
val startingPage = uriToRoute(uri)
LocalPreferences.migrateSingleUserPrefs()
val language = LocalPreferences.getPreferredLanguage()
if (language.isNotBlank()) {
@@ -138,12 +135,6 @@ class MainActivity : AppCompatActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
println("AAA -OnNew Intent")
}
@OptIn(DelicateCoroutinesApi::class)
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
// network is available for use
@@ -8,6 +8,7 @@ import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
@@ -189,17 +190,20 @@ class ImgurServer : FileServer() {
}
class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files"
override fun parseUrlFromSuccess(body: String): String? {
val url = jacksonObjectMapper().readTree(body) // return url.toString()
val tree = jacksonObjectMapper().readTree(body)
val data = tree?.get("data")
val data0 = data?.get(0)
val url = data0?.get("url")
return url.toString().replace("\"", "")
}
override fun inputParameterName(contentType: String?): String {
return "fileToUpload"
return "file"
}
override fun clientID(info: String) = null
override fun clientID(info: String) = ImageUploader.NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info)
}
class NostrFilesDevServer : FileServer() {
@@ -7,14 +7,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -28,8 +29,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) {
@@ -44,7 +43,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp)
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
@@ -57,14 +56,10 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
onClose()
})
val scope = rememberCoroutineScope()
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.create()
onClose()
}
postViewModel.create()
onClose()
},
postViewModel.channelName.value.text.isNotBlank()
)
@@ -3,8 +3,11 @@ package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.PublicChatChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class NewChannelViewModel : ViewModel() {
private var account: Account? = null
@@ -25,24 +28,26 @@ class NewChannelViewModel : ViewModel() {
}
fun create() {
this.account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text
)
} else {
account.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
viewModelScope.launch(Dispatchers.IO) {
account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text
)
} else {
account.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
}
}
}
clear()
clear()
}
}
fun clear() {
@@ -45,6 +45,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@@ -76,6 +76,7 @@ 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.model.ServersAvailable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
@@ -94,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -736,16 +738,32 @@ fun DisplayLocationObserver(geoLocation: Flow<String>) {
@Composable
fun DisplayLocationInTitle(geohash: String) {
val context = LocalContext.current
val cityName = remember(geohash) {
ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)
var cityName by remember(geohash) {
mutableStateOf<String>(geohash)
}
Text(
text = cityName ?: geohash,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = Size5dp)
)
LaunchedEffect(key1 = geohash) {
launch(Dispatchers.IO) {
val newCityName = ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { null }
if (newCityName != null && newCityName != cityName) {
cityName = newCityName
}
}
}
if (geohash != "s0000") {
Text(
text = cityName,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = Size5dp)
)
} else {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
}
}
@OptIn(ExperimentalLayoutApi::class)
@@ -1099,21 +1117,6 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
}
}
enum class ServersAvailable {
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
NOSTRCHECK_ME,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NOSTRCHECK_ME_NIP_94,
NIP95
}
@Composable
fun ImageVideoDescription(
uri: Uri,
@@ -13,6 +13,7 @@ import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import okio.Buffer
@@ -36,7 +37,7 @@ private fun svgString(msg: String): String {
checkNotInMainThread()
val hash = CryptoUtils.sha256(msg.toByteArray())
val hashHex = hash.joinToString(separator = "") { b -> "%02x".format(b) }
val hashHex = hash.toHexKey()
val bgColor = bytesToRGB(hash[0], hash[1], hash[2])
val fgColor = bytesToRGB(hash[3], hash[4], hash[5])
val bodyIndex = byteMod10(hash[6])
@@ -94,6 +95,7 @@ object Robohash {
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory)
.addHeader("Cache-Control", "max-age=31536000")
.build()
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -64,19 +65,30 @@ fun RobohashFallbackAsyncImage(
model = Robohash.imageRequest(context, robot)
)
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
if (model != null) {
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
Image(
painter = painter,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
colorFilter = colorFilter
)
}
}
@Composable
@@ -30,7 +30,7 @@ fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel)
} else {
var urlPreviewState by remember(url) {
mutableStateOf(
UrlCachedPreviewer.cache.get(url)?.let { it } ?: UrlPreviewState.Loading
UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading
)
}
@@ -39,9 +39,7 @@ fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel)
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
launch(Dispatchers.Main) {
urlPreviewState = it
}
urlPreviewState = it
}
}
}
@@ -65,7 +65,6 @@ fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel,
startingPage: String? = null,
themeViewModel: ThemeViewModel
) {
val scope = rememberCoroutineScope()
@@ -250,7 +249,10 @@ fun AppNavigation(
}
}
var actionableNextPage by remember { mutableStateOf(startingPage) }
val activity = LocalContext.current.getActivity()
var actionableNextPage by remember {
mutableStateOf(uriToRoute(activity.intent?.data?.toString()?.ifBlank { null }))
}
actionableNextPage?.let {
LaunchedEffect(it) {
navController.navigate(it) {
@@ -261,7 +263,6 @@ fun AppNavigation(
actionableNextPage = null
}
val activity = LocalContext.current.getActivity()
DisposableEffect(navController) {
val consumer = Consumer<Intent> { intent ->
val uri = intent?.data?.toString()
@@ -11,11 +11,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -39,8 +36,8 @@ 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -78,11 +75,20 @@ 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.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.CommunityHeader
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
@@ -109,29 +115,77 @@ fun AppTopBar(
}
}
RenderTopRouteBar(currentRoute, followLists, scaffoldState, accountViewModel, nav)
val id by remember(navEntryState.value) {
derivedStateOf {
navEntryState.value?.arguments?.getString("id")
}
}
RenderTopRouteBar(currentRoute, id, followLists, scaffoldState, accountViewModel, nav)
}
@Composable
private fun RenderTopRouteBar(
currentRoute: String?,
id: String?,
followLists: FollowListViewModel,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (currentRoute) {
Route.Channel.base -> NoTopBar()
Route.Room.base -> NoTopBar()
Route.Community.base -> NoTopBar()
Route.Hashtag.base -> NoTopBar()
Route.Geohash.base -> NoTopBar()
// Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Discover.base -> DiscoveryTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel, nav)
else -> MainTopBar(scaffoldState, accountViewModel, nav)
else -> {
if (id != null) {
when (currentRoute) {
Route.Channel.base -> LoadChannel(baseChannelHex = id) {
ChannelHeader(
baseChannel = it,
showVideo = true,
showBottomDiviser = true,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 11.dp),
accountViewModel = accountViewModel,
nav = nav
)
}
Route.Room.base -> LoadUser(baseUserHex = id) {
if (it != null) {
ChatroomHeader(
baseUser = it,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 11.dp),
accountViewModel = accountViewModel,
nav = nav
)
} else {
Spacer(BottomTopHeight)
}
}
Route.Community.base -> LoadAddressableNote(aTagHex = id) {
if (it != null) {
CommunityHeader(
baseNote = it,
showBottomDiviser = true,
sendToCommunity = false,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 10.dp),
accountViewModel = accountViewModel,
nav = nav
)
} else {
Spacer(BottomTopHeight)
}
}
Route.Hashtag.base -> HashtagHeader(id, Modifier.padding(vertical = 0.dp, horizontal = 10.dp), accountViewModel)
Route.Geohash.base -> GeoHashHeader(id, Modifier.padding(vertical = 0.dp, horizontal = 10.dp), accountViewModel)
else -> MainTopBar(scaffoldState, accountViewModel, nav)
}
} else {
MainTopBar(scaffoldState, accountViewModel, nav)
}
}
}
}
@@ -279,17 +333,14 @@ private fun LoggedInUserPictureDrawer(
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
IconButton(
onClick = onClick,
modifier = Modifier
onClick = onClick
) {
RobohashAsyncImageProxy(
robot = pubkeyHex,
model = profilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(34.dp)
.height(34.dp)
.clip(shape = CircleShape)
modifier = HeaderPictureModifier,
contentScale = ContentScale.Crop
)
}
}
@@ -514,6 +565,8 @@ fun debugState(context: Context) {
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
Log.d("STATE DUMP", "Memory used by Events: " + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB")
LocalCache.notes.values.groupBy { it.event?.kind() }.forEach {
Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ")
}
@@ -33,12 +33,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@@ -58,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
@@ -68,6 +67,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -254,9 +255,11 @@ fun NormalChatNote(
if (routeForLastRead != null) {
LaunchedEffect(key1 = routeForLastRead) {
val createdAt = note.createdAt()
if (createdAt != null) {
accountViewModel.account.markAsRead(routeForLastRead, createdAt)
launch(Dispatchers.IO) {
val createdAt = note.createdAt()
if (createdAt != null) {
accountViewModel.account.markAsRead(routeForLastRead, createdAt)
}
}
}
}
@@ -573,18 +576,18 @@ private fun StatusRow(
@Composable
fun ChatTimeAgo(baseNote: Note) {
val context = LocalContext.current
val nowStr = stringResource(id = R.string.now)
val timeStr by remember(baseNote) {
val time by remember(baseNote) {
derivedStateOf {
timeAgoShort(baseNote.createdAt() ?: 0, context = context)
timeAgoShort(baseNote.createdAt() ?: 0, nowStr)
}
}
Text(
text = timeStr,
text = time,
color = MaterialTheme.colors.placeholderText,
fontSize = 12.sp,
fontSize = Font12SP,
maxLines = 1
)
}
@@ -50,7 +50,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
val newItemColor = MaterialTheme.colors.newItemBackgroundColor
LaunchedEffect(key1 = messageSetCard) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
val isNew = messageSetCard.createdAt() > accountViewModel.account.loadLastRead(routeForLastRead)
accountViewModel.account.markAsRead(routeForLastRead, messageSetCard.createdAt())
@@ -162,7 +162,6 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun Galeries(
multiSetCard: MultiSetCard,
@@ -259,7 +258,9 @@ fun RenderZapGallery(
) {
ZappedIcon(
modifier = remember {
Modifier.size(Size25dp).align(Alignment.TopEnd)
Modifier
.size(Size25dp)
.align(Alignment.TopEnd)
}
)
}
@@ -281,7 +282,11 @@ fun RenderBoostGallery(
modifier = NotificationIconModifierSmaller
) {
RepostedIcon(
modifier = remember { Modifier.size(Size19dp).align(Alignment.TopEnd) }
modifier = remember {
Modifier
.size(Size19dp)
.align(Alignment.TopEnd)
}
)
}
@@ -344,20 +349,9 @@ private fun ParseAuthorCommentAndAmount(
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
launch(Dispatchers.IO) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
val newState = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
launch(Dispatchers.Main) { content.value = newState }
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
val newState = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
launch(Dispatchers.Main) { content.value = newState }
}
}
val newState = loadAmountState(zapRequest, zapEvent, accountViewModel)
if (newState != null) {
content.value = newState
}
}
}
@@ -365,6 +359,34 @@ private fun ParseAuthorCommentAndAmount(
onReady(content)
}
private suspend fun loadAmountState(
zapRequest: Note,
zapEvent: Note?,
accountViewModel: AccountViewModel
): ZapAmountCommentNotification? {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
return ZapAmountCommentNotification(
newAuthor,
decryptedContent.content.ifBlank { null },
showAmountAxis(amount)
)
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
return ZapAmountCommentNotification(
zapRequest.author,
zapRequest.event?.content()?.ifBlank { null },
showAmountAxis(amount)
)
}
}
}
return null
}
@Composable
private fun RenderState(
content: MutableState<ZapAmountCommentNotification>,
@@ -160,6 +160,7 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
@@ -172,6 +173,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
@@ -511,29 +513,32 @@ fun CommunityHeader(
) {
val expanded = remember { mutableStateOf(false) }
Column(
modifier = modifier.clickable {
if (sendToCommunity) {
routeFor(baseNote, accountViewModel.userProfile())?.let {
nav(it)
Column(Modifier.fillMaxWidth()) {
Column(
verticalArrangement = Arrangement.Center,
modifier = modifier.clickable {
if (sendToCommunity) {
routeFor(baseNote, accountViewModel.userProfile())?.let {
nav(it)
}
} else {
expanded.value = !expanded.value
}
} else {
expanded.value = !expanded.value
}
) {
ShortCommunityHeader(baseNote, expanded, accountViewModel, nav)
if (expanded.value) {
LongCommunityHeader(baseNote, accountViewModel, nav)
}
}
) {
ShortCommunityHeader(baseNote, expanded, accountViewModel, nav)
if (expanded.value) {
LongCommunityHeader(baseNote, accountViewModel, nav)
if (showBottomDiviser) {
Divider(
thickness = 0.25.dp
)
}
}
if (showBottomDiviser) {
Divider(
thickness = 0.25.dp
)
}
}
@Composable
@@ -690,11 +695,7 @@ fun ShortCommunityHeader(baseNote: AddressableNote, expanded: MutableState<Boole
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
modifier = HeaderPictureModifier
)
}
@@ -1949,6 +1950,26 @@ fun RenderPostApproval(
}
}
@Composable
fun LoadAddressableNote(aTagHex: String, content: @Composable (AddressableNote?) -> Unit) {
var note by remember(aTagHex) {
mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTagHex))
}
if (note == null) {
LaunchedEffect(key1 = aTagHex) {
launch(Dispatchers.IO) {
val newNote = LocalCache.checkGetOrCreateAddressableNote(aTagHex)
if (newNote != note) {
note = newNote
}
}
}
}
content(note)
}
@Composable
fun LoadAddressableNote(aTag: ATag, content: @Composable (AddressableNote?) -> Unit) {
var note by remember(aTag) {
@@ -1958,7 +1979,10 @@ fun LoadAddressableNote(aTag: ATag, content: @Composable (AddressableNote?) -> U
if (note == null) {
LaunchedEffect(key1 = aTag) {
launch(Dispatchers.IO) {
note = LocalCache.getOrCreateAddressableNote(aTag)
val newNote = LocalCache.getOrCreateAddressableNote(aTag)
if (newNote != note) {
note = newNote
}
}
}
}
@@ -2427,12 +2451,21 @@ fun SecondUserInfoRow(
@Composable
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
val context = LocalContext.current
val cityName = remember(geohash) {
ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)
var cityName by remember(geohash) {
mutableStateOf<String>(geohash)
}
LaunchedEffect(key1 = geohash) {
launch(Dispatchers.IO) {
val newCityName = ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { null }
if (newCityName != null && newCityName != cityName) {
cityName = newCityName
}
}
}
ClickableText(
text = AnnotatedString(cityName ?: geohash),
text = AnnotatedString(cityName),
onClick = { nav("Geohash/$geohash") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
@@ -3222,7 +3255,7 @@ private fun ObserverAndRenderNIP95(
// Creates a new object when the event arrives to force an update of the image.
val note = noteState?.note
val uri = header.toNostrUri()
val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) }
val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.content
@@ -3505,6 +3538,7 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
Row(
modifier = Modifier
.padding(top = Size5dp)
.clip(shape = QuoteBorder)
.border(
1.dp,
@@ -112,6 +112,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import kotlin.math.roundToInt
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -411,65 +412,97 @@ private fun ReactionDetailGallery(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val zapsState by baseNote.live().zaps.observeAsState()
val boostsState by baseNote.live().boosts.observeAsState()
val reactionsState by baseNote.live().reactions.observeAsState()
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
val hasReactions by remember(zapsState, boostsState, reactionsState) {
derivedStateOf {
baseNote.zaps.isNotEmpty() ||
baseNote.boosts.isNotEmpty() ||
baseNote.reactions.isNotEmpty()
}
}
val hasReactions by baseNote.live().zaps.combineWith(
baseNote.live().boosts,
baseNote.live().reactions
) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() ?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged().observeAsState(
baseNote.zaps.isNotEmpty() || baseNote.boosts.isNotEmpty() || baseNote.reactions.isNotEmpty()
)
if (hasReactions) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp)) {
Column() {
val zapEvents by remember(zapsState) { derivedStateOf { baseNote.zaps.mapNotNull { it.value?.let { zapEvent -> CombinedZap(it.key, zapEvent) } }.toImmutableList() } }
val boostEvents by remember(boostsState) { derivedStateOf { baseNote.boosts.toImmutableList() } }
val likeEvents by remember(reactionsState) { derivedStateOf { baseNote.reactions.toImmutableMap() } }
val hasZapEvents by remember(zapsState) { derivedStateOf { baseNote.zaps.isNotEmpty() } }
val hasBoostEvents by remember(boostsState) { derivedStateOf { baseNote.boosts.isNotEmpty() } }
val hasLikeEvents by remember(reactionsState) { derivedStateOf { baseNote.reactions.isNotEmpty() } }
if (hasZapEvents) {
RenderZapGallery(
zapEvents,
backgroundColor,
nav,
accountViewModel
)
}
if (hasBoostEvents) {
RenderBoostGallery(
boostEvents,
nav,
accountViewModel
)
}
if (hasLikeEvents) {
likeEvents.forEach {
val reactions = remember(it.value) { it.value.toImmutableList() }
RenderLikeGallery(
it.key,
reactions,
nav,
accountViewModel
)
}
}
WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel)
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
}
}
}
}
@Composable
private fun WatchBoostsAndRenderGallery(
baseNote: Note,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostsEvents by remember(boostsState) {
derivedStateOf { baseNote.boosts.toImmutableList() }
}
if (boostsEvents.isNotEmpty()) {
RenderBoostGallery(
boostsEvents,
nav,
accountViewModel
)
}
}
@Composable
private fun WatchReactionsAndRenderGallery(
baseNote: Note,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactionEvents by remember(reactionsState) {
derivedStateOf { baseNote.reactions.toImmutableMap() }
}
if (reactionEvents.isNotEmpty()) {
reactionEvents.forEach {
val reactions = remember(it.value) { it.value.toImmutableList() }
RenderLikeGallery(
it.key,
reactions,
nav,
accountViewModel
)
}
}
}
@Composable
private fun WatchZapAndRenderGallery(
baseNote: Note,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zapEvents by remember(zapsState) {
derivedStateOf { baseNote.zaps.mapNotNull { it.value?.let { zapEvent -> CombinedZap(it.key, zapEvent) } }.toImmutableList() }
}
if (zapEvents.isNotEmpty()) {
RenderZapGallery(
zapEvents,
backgroundColor,
nav,
accountViewModel
)
}
}
@Composable
private fun BoostWithDialog(
baseNote: Note,
@@ -1456,14 +1489,19 @@ val OneGiga = BigDecimal(1000000000)
val OneMega = BigDecimal(1000000)
val OneKilo = BigDecimal(1000)
var dfG: DecimalFormat = DecimalFormat("#.0G")
var dfM: DecimalFormat = DecimalFormat("#.0M")
var dfK: DecimalFormat = DecimalFormat("#.0k")
var dfN: DecimalFormat = DecimalFormat("#")
fun showAmount(amount: BigDecimal?): String {
if (amount == null) return ""
if (amount.abs() < BigDecimal(0.01)) return ""
return when {
amount >= OneGiga -> "%.1fG".format(amount.div(OneGiga).setScale(1, RoundingMode.HALF_UP))
amount >= OneMega -> "%.1fM".format(amount.div(OneMega).setScale(1, RoundingMode.HALF_UP))
amount >= OneKilo -> "%.1fk".format(amount.div(OneKilo).setScale(1, RoundingMode.HALF_UP))
else -> "%.0f".format(amount)
amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(1, RoundingMode.HALF_UP))
amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(1, RoundingMode.HALF_UP))
amount >= OneKilo -> dfK.format(amount.div(OneKilo).setScale(1, RoundingMode.HALF_UP))
else -> dfN.format(amount)
}
}
@@ -27,7 +27,7 @@ fun timeAgo(mills: Long?, context: Context): String {
.replace("Yesterday", "1" + context.getString(R.string.d))
}
fun timeAgoShort(mills: Long?, context: Context): String {
fun timeAgoShort(mills: Long?, stringForNow: String): String {
if (mills == null) return " "
var humanReadable = DateUtils.getRelativeTimeSpanString(
@@ -37,7 +37,7 @@ fun timeAgoShort(mills: Long?, context: Context): String {
DateUtils.FORMAT_ABBREV_ALL
).toString()
if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) {
humanReadable = context.getString(R.string.now)
humanReadable = stringForNow
}
return humanReadable
@@ -67,6 +67,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
@@ -11,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
@@ -86,6 +88,7 @@ fun ChatroomFeedLoaded(
top = 10.dp,
bottom = 10.dp
),
modifier = Modifier.fillMaxSize(),
reverseLayout = true,
state = listState
) {
@@ -292,10 +292,12 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
override fun invalidateData(ignoreIfDoing: Boolean) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
}
@@ -329,6 +329,11 @@ fun NoteMaster(
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(baseNote, remember { Modifier.weight(1f) })
val geo = remember { noteEvent.getGeoHash() }
if (geo != null) {
DisplayLocation(geo, nav)
}
val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } }
if (baseReward != null) {
DisplayReward(baseReward, baseNote, accountViewModel, nav)
@@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -85,6 +84,7 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
@@ -94,7 +94,6 @@ import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.LoadNote
@@ -127,7 +126,9 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldLeadingIconModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -196,7 +197,7 @@ fun ChannelScreen(
LaunchedEffect(Unit) {
NostrChannelDataSource.start()
feedViewModel.invalidateData()
feedViewModel.invalidateData(true)
launch(Dispatchers.IO) {
newPostModel.imageUploadingError.collect { error ->
@@ -212,7 +213,7 @@ fun ChannelScreen(
if (event == Lifecycle.Event.ON_RESUME) {
println("Channel Start")
NostrChannelDataSource.start()
feedViewModel.invalidateData()
feedViewModel.invalidateData(true)
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Channel Stop")
@@ -229,14 +230,6 @@ fun ChannelScreen(
}
Column(Modifier.fillMaxHeight()) {
ChannelHeader(
baseChannel = channel,
showVideo = true,
showBottomDiviser = true,
accountViewModel = accountViewModel,
nav = nav
)
val replyTo = remember { mutableStateOf<Note?>(null) }
Column(
@@ -571,6 +564,7 @@ fun ChannelHeader(
val expanded = remember { mutableStateOf(false) }
Column(
verticalArrangement = Arrangement.Center,
modifier = modifier.clickable {
if (sendToChannel) {
nav(routeFor(baseChannel))
@@ -670,7 +664,7 @@ private fun ShortChannelHeader(
channel.creator?.let {
UserPicture(
user = it,
size = Size35dp,
size = Size34dp,
accountViewModel = accountViewModel,
nav = nav
)
@@ -682,11 +676,7 @@ private fun ShortChannelHeader(
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
modifier = HeaderPictureModifier
)
}
}
@@ -2,10 +2,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
@@ -36,7 +38,8 @@ import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -129,10 +132,7 @@ fun ChatroomScreen(
}
Column(Modifier.fillMaxHeight()) {
ChatroomHeader(baseUser, accountViewModel, nav = nav)
val replyTo = remember { mutableStateOf<Note?>(null) }
Column(
modifier = Modifier
.fillMaxHeight()
@@ -179,34 +179,36 @@ fun ChatroomScreen(
}
@Composable
fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun ChatroomHeader(
baseUser: User,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(
modifier = Modifier.clickable(
modifier = Modifier.fillMaxWidth().clickable(
onClick = { nav("User/${baseUser.pubkeyHex}") }
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Column(
verticalArrangement = Arrangement.Center,
modifier = modifier
) {
Row(verticalAlignment = Alignment.CenterVertically) {
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = Size35dp
size = Size34dp
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseUser)
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(baseUser)
}
UsernameDisplay(baseUser)
ObserveDisplayNip05Status(baseUser)
}
}
}
Divider(
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
@@ -6,8 +6,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -15,39 +13,24 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.ui.note.CommunityHeader
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.NostrCommunityFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun CommunityScreen(aTagHex: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
if (aTagHex == null) return
var noteDefBase by remember { mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTagHex)) }
if (noteDefBase == null) {
LaunchedEffect(aTagHex) {
// waits to resolve.
launch(Dispatchers.IO) {
val newNote = LocalCache.checkGetOrCreateAddressableNote(aTagHex)
if (newNote != noteDefBase) {
noteDefBase = newNote
}
}
LoadAddressableNote(aTagHex = aTagHex) {
it?.let {
PrepareViewModelsCommunityScreen(
note = it,
accountViewModel = accountViewModel,
nav = nav
)
}
}
noteDefBase?.let {
PrepareViewModelsCommunityScreen(
note = it,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
@@ -94,13 +77,6 @@ fun CommunityScreen(note: AddressableNote, feedViewModel: NostrCommunityFeedView
}
Column(Modifier.fillMaxSize()) {
CommunityHeader(
baseNote = note,
showBottomDiviser = true,
sendToCommunity = false,
accountViewModel = accountViewModel,
nav = nav
)
RefresheableFeedView(
feedViewModel,
null,
@@ -16,6 +16,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -34,7 +35,7 @@ import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.ui.screen.NostrGeoHashFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -96,7 +97,6 @@ fun GeoHashScreen(tag: String, feedViewModel: NostrGeoHashFeedViewModel, account
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
GeoHashHeader(tag, accountViewModel)
RefresheableFeedView(
feedViewModel,
null,
@@ -108,46 +108,51 @@ fun GeoHashScreen(tag: String, feedViewModel: NostrGeoHashFeedViewModel, account
}
@Composable
fun GeoHashHeader(tag: String, account: AccountViewModel, onClick: () -> Unit = { }) {
fun GeoHashHeader(tag: String, modifier: Modifier = StdPadding, account: AccountViewModel, onClick: () -> Unit = { }) {
Column(
Modifier.clickable { onClick() }
Modifier.fillMaxWidth().clickable { onClick() }
) {
Column(modifier = HalfPadding) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
val context = LocalContext.current
val cityName = remember(tag) {
ReverseGeoLocationUtil().execute(tag.toGeoHash().toLocation(), context)
}
Column(modifier = modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
DislayGeoTagHeader(tag, remember { Modifier.weight(1f) })
Text(
"$cityName ($tag)",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
HashtagActionOptions(tag, account)
}
}
HashtagActionOptions(tag, account)
}
}
Divider(
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
}
@Composable
fun DislayGeoTagHeader(geohash: String, modifier: Modifier) {
val context = LocalContext.current
var cityName by remember(geohash) {
mutableStateOf<String>(geohash)
}
LaunchedEffect(key1 = geohash) {
launch(Dispatchers.IO) {
val newCityName = ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { null }
if (newCityName != null && newCityName != cityName) {
cityName = "$newCityName ($geohash)"
}
}
}
Text(
cityName,
fontWeight = FontWeight.Bold,
modifier = modifier
)
}
@Composable
private fun HashtagActionOptions(
tag: String,
@@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -93,7 +93,6 @@ fun HashtagScreen(tag: String, feedViewModel: NostrHashtagFeedViewModel, account
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, accountViewModel)
RefresheableFeedView(
feedViewModel,
null,
@@ -105,36 +104,26 @@ fun HashtagScreen(tag: String, feedViewModel: NostrHashtagFeedViewModel, account
}
@Composable
fun HashtagHeader(tag: String, account: AccountViewModel, onClick: () -> Unit = { }) {
fun HashtagHeader(tag: String, modifier: Modifier = StdPadding, account: AccountViewModel, onClick: () -> Unit = { }) {
Column(
Modifier.clickable { onClick() }
Modifier.fillMaxWidth().clickable { onClick() }
) {
Column(modifier = HalfPadding) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
"#$tag",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Column(modifier = modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
"#$tag",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
HashtagActionOptions(tag, account)
}
}
HashtagActionOptions(tag, account)
}
}
Divider(
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import kotlin.math.roundToInt
@Composable
@@ -243,14 +244,19 @@ class AmountAxisValueFormatter() : AxisValueFormatter<AxisPosition.Vertical.End>
}
}
var dfG: DecimalFormat = DecimalFormat("#G")
var dfM: DecimalFormat = DecimalFormat("#M")
var dfK: DecimalFormat = DecimalFormat("#k")
var dfN: DecimalFormat = DecimalFormat("#")
fun showAmountAxis(amount: BigDecimal?): String {
if (amount == null) return ""
if (amount.abs() < BigDecimal(0.01)) return ""
return when {
amount >= OneGiga -> "%.0fG".format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
amount >= OneMega -> "%.0fM".format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
amount >= OneKilo -> "%.0fk".format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
else -> "%.0f".format(amount)
amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
amount >= OneKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
else -> dfN.format(amount)
}
}
@@ -48,6 +48,7 @@ val HalfDoubleVertSpacer = Modifier.height(7.dp)
val Size0dp = 0.dp
val Size5dp = 5.dp
val Size10dp = 10.dp
val Size12dp = 12.dp
val Size13dp = 13.dp
val Size15dp = 15.dp
val Size16dp = 16.dp
@@ -59,6 +60,7 @@ val Size22dp = 22.dp
val Size24dp = 24.dp
val Size25dp = 25.dp
val Size30dp = 30.dp
val Size34dp = 34.dp
val Size35dp = 35.dp
val Size55dp = 55.dp
val Size75dp = 75.dp
@@ -104,7 +106,8 @@ val UserNameMaxRowHeight = Modifier.fillMaxWidth()
val Height4dpModifier = Modifier.height(4.dp)
val AccountPictureModifier = Modifier.width(55.dp).height(55.dp).clip(shape = CircleShape)
val AccountPictureModifier = Modifier.size(55.dp).clip(shape = CircleShape)
val HeaderPictureModifier = Modifier.size(34.dp).clip(shape = CircleShape)
val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(24.dp)
val ShowMoreRelaysButtonIconModifier = Modifier.size(15.dp)
@@ -30,6 +30,7 @@ val Typography = Typography(
*/
)
val Font12SP = 12.sp
val Font14SP = 14.sp
val Font17SP = 17.sp
+325 -4
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="point_to_the_qr_code">Pointer vers le QR code</string>
<string name="show_qr">Montrer le QR code</string>
<string name="profile_image">Image de profil</string>
@@ -92,7 +92,7 @@
<string name="image_saved_to_the_gallery">Image enregistrée dans la galerie</string>
<string name="failed_to_save_the_image">Échec de l\'enregistrement de l\'image</string>
<string name="upload_image">Charger une image</string>
<string name="uploading">Chargement...</string>
<string name="uploading">Chargement</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string>
<string name="reply_here">"Répondre ici.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copie l\'ID de note dans le presse-papiers pour le partage</string>
@@ -169,7 +169,11 @@
<string name="report_hateful_speech">Signaler Discours haineux</string>
<string name="report_nudity_porn">Signaler Nudité / Porno</string>
<string name="others">autres</string>
<string name="account_backup_tips_md">
<string name="mark_all_known_as_read">Marquer tous les Connus comme lu</string>
<string name="mark_all_new_as_read">Marquer tous les Nouveaux comme lu</string>
<string name="mark_all_as_read">Tout marquer comme lu</string>
<string name="backup_keys">Clés de sauvegarde</string>
<string name="account_backup_tips_md" tools:ignore="Typos">
## Sauvegarde des clés et conseils de sécurité
\n\nVotre compte est sécurisé par une clé secrète. La clé est une longue chaîne aléatoire commençant par **nsec1**. Toute personne ayant accès à votre clé secrète peut publier du contenu en utilisant votre identité.
\n\n- Ne communiquez **jamais** votre clé secrète à un site Web ou un logiciel auquel vous ne faites pas confiance.
@@ -177,5 +181,322 @@
\n- **Conservez** une sauvegarde sécurisée de votre clé secrète pour la récupération de compte. Nous vous recommandons d\'utiliser un gestionnaire de mots de passe.
</string>
<string name="secret_key_copied_to_clipboard">Clé secrète (nsec) copiée dans le presse-papiers</string>
<string name="backup_keys">Clés de sauvegarde</string>
<string name="copy_my_secret_key">Copier ma clé secrète</string>
<string name="biometric_authentication_failed">Échec de l\'authentification</string>
<string name="biometric_error">Erreur</string>
<string name="badge_created_by">"Créé par %1$s"</string>
<string name="badge_award_image_for">"Image de badge pour %1$s"</string>
<string name="new_badge_award_notif">Vous avez reçu un nouveau Badge</string>
<string name="award_granted_to">Le Badge a été décerné à</string>
<string name="copied_note_text_to_clipboard">Copie du contenu de la note dans le presse-papiers</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">Copie du @npub utilisateur dans le presse-papiers</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Copie de l\'ID de la note (@note1) dans le presse-papiers</string>
<string name="select_text_dialog_top">Sélectionner le Texte</string>
<string name="private_conversation_notification">"&lt;Impossible de déchiffrer le message privé&gt;\n\nVous avez été cité dans une conversation privée/cryptée entre %1$s et %2$s."</string>
<string name="account_switch_add_account_dialog_title">Ajouter un Nouveau Compte</string>
<string name="drawer_accounts">Comptes</string>
<string name="account_switch_select_account">Sélectionner un Compte</string>
<string name="account_switch_add_account_btn">Ajouter un Nouveau Compte</string>
<string name="account_switch_active_account">Compte actif</string>
<string name="account_switch_has_private_key">Avec une clé privée</string>
<string name="account_switch_pubkey_only">Lecture seule, pas de clé privée</string>
<string name="back">Retour</string>
<string name="quick_action_select">Sélectionner</string>
<string name="quick_action_share_browser_link">Partager le Lien Navigateur</string>
<string name="quick_action_share">Partager</string>
<string name="quick_action_copy_user_id">ID Author</string>
<string name="quick_action_copy_note_id">ID Note</string>
<string name="quick_action_copy_text">Copier le Texte</string>
<string name="quick_action_delete">Supprimer</string>
<string name="quick_action_unfollow">Ne plus suivre</string>
<string name="quick_action_follow">Suivre</string>
<string name="quick_action_request_deletion_alert_title">Demande de Suppression</string>
<string name="quick_action_request_deletion_alert_body">Amethyst demandera que votre note soit supprimée des relais auxquels vous êtes actuellement connecté. Il n\'y a aucune garantie que votre note sera définitivement supprimée de ces relais, ou d\'autres relais où elle peut être stockée.</string>
<string name="quick_action_block_dialog_btn">Bloquer</string>
<string name="quick_action_delete_dialog_btn">Supprimer</string>
<string name="quick_action_block">Bloquer</string>
<string name="quick_action_report">Signaler</string>
<string name="quick_action_delete_button">Supprimer</string>
<string name="quick_action_dont_show_again_button">Ne plus montrer</string>
<string name="report_dialog_spam">Spam ou arnaque</string>
<string name="report_dialog_profanity">Grossièreté ou contenu haineux</string>
<string name="report_dialog_impersonation">Usurpation d\'identité</string>
<string name="report_dialog_nudity">Nudité ou contenu graphique</string>
<string name="report_dialog_illegal">Comportement illégal</string>
<string name="report_dialog_blocking_a_user">Bloquer un utilisateur cachera son contenu dans votre application. Vos notes sont toujours visibles publiquement, y compris pour les personnes que vous bloquez. Les utilisateurs bloqués sont listés sur l\'écran Filtres de Sécurité.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Bloquer et Masquer l\'Utilisateur]]></string>
<string name="report_dialog_report_btn">Signaler un Abus</string>
<string name="report_dialog_reminder_public">Tous les rapports publiés seront visibles publiquement.</string>
<string name="report_dialog_additional_reason_placeholder">Fournir un contexte optionnel supplémentaire sur votre signalement…</string>
<string name="report_dialog_additional_reason_label">Contexte additionnel</string>
<string name="report_dialog_select_reason_label">Raison</string>
<string name="report_dialog_select_reason_placeholder">Sélectionner une raison…</string>
<string name="report_dialog_post_report_btn">Publier le signalement</string>
<string name="report_dialog_title">Bloquer et Signaler</string>
<string name="block_only">Bloquer</string>
<string name="bookmarks">Favoris</string>
<string name="private_bookmarks">Favoris Privés</string>
<string name="public_bookmarks">Favoris Publics</string>
<string name="add_to_private_bookmarks">Ajouter aux Favoris Privés</string>
<string name="add_to_public_bookmarks">Ajouter aux Favoris Publics</string>
<string name="remove_from_private_bookmarks">Supprimer des Favoris Privés</string>
<string name="remove_from_public_bookmarks">Supprimer des Favoris Publics</string>
<string name="wallet_connect_service">Service Wallet Connect</string>
<string name="wallet_connect_service_explainer">Autorise un Nostr Secret à payer des zaps sans quitter l\'application. Gardez le secret en toute sécurité et utilisez un relais privé si possible</string>
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
<string name="wallet_connect_service_show_secret">Montrer la clé secrète</string>
<string name="wallet_connect_service_secret_placeholder">Clé privée nsec / hex</string>
<string name="pledge_amount_in_sats">Montant engagé en Sats</string>
<string name="post_poll">Message Sondage</string>
<string name="poll_heading_required">Champs requis:</string>
<string name="poll_zap_recipients">Bénéficiaires du Zap</string>
<string name="poll_primary_description">Description du sondage primaire…</string>
<string name="poll_option_index">Option %s</string>
<string name="poll_option_description">Description de l\'option du Sondage</string>
<string name="poll_heading_optional">Champs optionels:</string>
<string name="poll_zap_value_min">Zap minimum</string>
<string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Consensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Clôturer après</string>
<string name="poll_closing_time_days">jours</string>
<string name="poll_is_closed">Le Sondage est fermé aux nouveaux votes</string>
<string name="poll_zap_amount">Montant des Zap</string>
<string name="one_vote_per_user_on_atomic_votes">Un seul vote par utilisateur est autorisé sur ce type de sondage</string>
<string name="looking_for_event">"Recherche de l'Evénement %1$s"</string>
<string name="custom_zaps_add_a_message">Ajouter un message public</string>
<string name="custom_zaps_add_a_message_private">Ajouter un message privé</string>
<string name="custom_zaps_add_a_message_nonzap">Ajouter un message facture</string>
<string name="custom_zaps_add_a_message_example">Merci pour tout votre travail !</string>
<string name="lightning_create_and_add_invoice">Créer et Ajouter</string>
<string name="poll_author_no_vote">Les auteurs du sondage ne peuvent pas voter dans leurs propres sondages.</string>
<string name="hash_verification_passed">Ce contenu est le même depuis le message</string>
<string name="hash_verification_failed">Ce contenu a changé. L\'auteur n\'a peut-être pas vu ou approuvé le changement</string>
<string name="content_description_add_image">Ajouter une Image</string>
<string name="content_description_add_video">Ajouter une Vidéo</string>
<string name="content_description_add_document">Ajouter un Document</string>
<string name="add_content">Ajouter au Message</string>
<string name="content_description">Description des contenus</string>
<string name="content_description_example">Un bateau bleu sur une plage de sable blanc au coucher du soleil</string>
<string name="zap_type">Type de Zap</string>
<string name="zap_type_explainer">Zap Type pour toutes les options</string>
<string name="zap_type_public">Public</string>
<string name="zap_type_public_explainer">Tout le monde peut voir la transaction et le message</string>
<string name="zap_type_private">Privé</string>
<string name="zap_type_private_explainer">L\'expéditeur et le receveur peuvent se voir et lire le message</string>
<string name="zap_type_anonymous">Anonyme</string>
<string name="zap_type_anonymous_explainer">Le receveur et le public ne savent pas qui a envoyé le paiement</string>
<string name="zap_type_nonzap">Non-Zap</string>
<string name="zap_type_nonzap_explainer">Aucune trace sur Nostr, seulement sur le Lightning</string>
<string name="file_server">Serveur fichier</string>
<string name="zap_forward_lnAddress">Adresse LN ou @Utilisateur</string>
<string name="upload_server_imgur">imgur.com - de confiance</string>
<string name="upload_server_imgur_explainer">Imgur peut modifier le fichier</string>
<string name="upload_server_nostrimg">nostrimg.com - de confiance</string>
<string name="upload_server_nostrimg_explainer">NostrImg peut modifier le fichier</string>
<string name="upload_server_nostrbuild">nostr.build - de confiance</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build peut modifier le fichier</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - de confiance</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev peut modifier le fichier</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - de confiance</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me peut modifier le fichier</string>
<string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Vérifie si Imgur a modifié le fichier. Nouveau NIP: certains clients pourraient ne pas le voir</string>
<string name="upload_server_nostrimg_nip94">Verifiable NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Vérifie si NostrImg a modifié le fichier. Nouveau NIP: certains clients pourraient ne pas le voir</string>
<string name="upload_server_nostrbuild_nip94">Verifiable Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Vérifie si Nostr.build a modifié le fichier. Nouveau NIP: certains clients pourraient ne pas le voir</string>
<string name="upload_server_nostrfilesdev_nip94">Verifiable Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Vérifie si Nostrfiles.dev a modifié le fichier. Nouveau NIP: certains clients pourraient ne pas le voir</string>
<string name="upload_server_nostrcheckme_nip94">Verifiable Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Vérifie si Nostrcheck.me a modifié le fichier. Nouveau NIP: certains clients pourraient ne pas le voir</string>
<string name="upload_server_relays_nip95">Vos relais (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Les fichiers sont hébergés par vos relais. Nouveau NIP: vérifiez s\'ils sont supportés</string>
<string name="connect_via_tor_short">Configuration Tor/Orbot</string>
<string name="connect_via_tor">Se connecter à travers la configuration Orbot</string>
<string name="do_you_really_want_to_disable_tor_title">Se déconnecter de Orbot/Tor?</string>
<string name="do_you_really_want_to_disable_tor_text">Vos données seront immédiatement transférées sur le réseau classique</string>
<string name="yes">Oui</string>
<string name="no">Non</string>
<string name="follow_list_selection">Liste des Abonnés</string>
<string name="follow_list_kind3follows">Tous les abonnements</string>
<string name="follow_list_global">Global</string>
<string name="connect_through_your_orbot_setup_markdown">
## Se connecter à travers Tor avec Orbot
\n\n1. Installe [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. Démarre Orbot
\n3. Dans Orbot, vérifie le port Socks. Par défaut 9050
\n4. Si nécessaire change le port dans Orbot
\n5. Configure le port Socks sur cet écran
\n6. Appuyer sur le bouton Activer pour utiliser Orbot comme proxy
</string>
<string name="orbot_socks_port">Port Socks Orbot</string>
<string name="invalid_port_number">Numéro de port invalide</string>
<string name="use_orbot">Utiliser Orbot</string>
<string name="disconnect_from_your_orbot_setup">Déconnecter Tor/Orbot</string>
<string name="app_notification_dms_channel_name">Messages privés</string>
<string name="app_notification_dms_channel_description">Vous notifie lorsqu\'un message privé est reçu</string>
<string name="app_notification_zaps_channel_name">Zaps reçus</string>
<string name="app_notification_zaps_channel_description">Vous notifie lorsque quelqu\'un vous zap</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">De %1$s</string>
<string name="app_notification_zaps_channel_message_for">pour %1$s</string>
<string name="reply_notify">Notifier: </string>
<string name="channel_list_join_conversation">Rejoindre Conversation</string>
<string name="channel_list_user_or_group_id">Utilisateur ou Groupe ID</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent ou hex</string>
<string name="channel_list_create_channel">Créer</string>
<string name="channel_list_join_channel">Rejoindre</string>
<string name="today">Aujourd\'hui</string>
<string name="content_warning">Avertissement de contenu</string>
<string name="content_warning_explanation">Ce message contient du contenu sensible que certaines personnes peuvent trouver offensant ou dérangeant</string>
<string name="content_warning_hide_all_sensitive_content">Toujours cacher les contenus sensibles</string>
<string name="content_warning_show_all_sensitive_content">Toujours afficher les contenus sensibles</string>
<string name="content_warning_see_warnings">Toujours afficher les avertissements de contenu</string>
<string name="recommended_apps">Recommendations: </string>
<string name="filter_spam_from_strangers">Filtrer le spam des étrangers</string>
<string name="warn_when_posts_have_reports_from_your_follows">Avertir lorsque les messages ont été rapportés par vos abonnés</string>
<string name="new_reaction_symbol">Nouveau symbole de réaction</string>
<string name="no_reaction_type_setup_long_press_to_change">Aucun type de réaction sélectionné. Appuyer longuement pour changer</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">Ajoute une quantité cible de sats pour ce message. Les clients compatible peuvent afficher une barre de progrès pour encourager les dons</string>
<string name="zapraiser_target_amount_in_sats">Quantité ciblée en Sats</string>
<string name="sats_to_complete">Zapraiser à %1$s. %2$s sats pour objectif</string>
<string name="read_from_relay">Lire depuis le Relay</string>
<string name="write_to_relay">Écrire sur le Relay</string>
<string name="an_error_occurred_trying_to_get_relay_information">Une erreur s\'est produite en récupérant les informations du relay depuis %1$s</string>
<string name="owner">Propriétaire</string>
<string name="version">Version</string>
<string name="software">Logiciel</string>
<string name="contact">Contact</string>
<string name="supports">NIPs supportés</string>
<string name="admission_fees">Frais d\'admission</string>
<string name="payments_url">URL de payments</string>
<string name="limitations">Limitations</string>
<string name="countries">Pays</string>
<string name="languages">Langages</string>
<string name="tags">Tags</string>
<string name="posting_policy">Politique de publication</string>
<string name="message_length">Longueur du message</string>
<string name="subscriptions">Abonnements</string>
<string name="filters">Filtres</string>
<string name="subscription_id_length">Longueur de l\'id de souscription</string>
<string name="minimum_prefix">Préfixe minimum</string>
<string name="maximum_event_tags">Tags d\'événement maximum</string>
<string name="content_length">Longueur du contenu</string>
<string name="minimum_pow">PoW minimum</string>
<string name="auth">Authentification</string>
<string name="payment">Paiement</string>
<string name="cashu">Jeton Cashu</string>
<string name="cashu_redeem">Échanger</string>
<string name="no_lightning_address_set">Adresse Lightning non définie</string>
<string name="copied_token_to_clipboard">Jeton copié dans le presse-papiers</string>
<string name="live_stream_live_tag">EN DIRECT</string>
<string name="live_stream_offline_tag">DÉCONNECTÉ</string>
<string name="live_stream_ended_tag">TERMINÉ</string>
<string name="live_stream_planned_tag">PLANIFIÉ</string>
<string name="live_stream_is_offline">Livestream est déconnecté</string>
<string name="live_stream_has_ended">Livestream terminé</string>
<string name="are_you_sure_you_want_to_log_out">Se déconnecter supprime toutes vos informations locales. Assurez-vous d\'avoir vos clés privées sauvegardées pour éviter de perdre votre compte. Voulez-vous continuer ?</string>
<string name="followed_tags">Tags suivis</string>
<string name="relay_setup">Relays</string>
<string name="discover_live">Direct</string>
<string name="discover_community">Communauté</string>
<string name="discover_chat">Chats</string>
<string name="community_approved_posts">Messages approuvés</string>
<string name="groups_no_descriptor">Ce groupe n\'a pas de description ou de règles. Parlez au propriétaire pour en ajouter une</string>
<string name="community_no_descriptor">Cette communauté n\'a pas de description. Parlez au propriétaire pour en ajouter une</string>
<string name="add_sensitive_content_label">Contenu sensible</string>
<string name="add_sensitive_content_description">Ajouter un avertissement de contenu sensible avant de montrer ce contenu</string>
<string name="settings">Paramètres</string>
<string name="connectivity_type_always">Toujours</string>
<string name="connectivity_type_wifi_only">Wifi uniquement</string>
<string name="connectivity_type_never">Jamais</string>
<string name="system">Système</string>
<string name="light">Clair</string>
<string name="dark">Sombre</string>
<string name="application_preferences">Préférences de l\'application</string>
<string name="language">Langage</string>
<string name="theme">Thème</string>
<string name="automatically_load_images_gifs">Chargement automatique des images/gifs</string>
<string name="automatically_play_videos">Démarrage automatique des vidéos</string>
<string name="automatically_show_url_preview">Prévisualisation automatique des liens</string>
<string name="load_image">Charger l\'image</string>
<string name="spamming_users">Spammeurs</string>
<string name="muted_button">Silencieux. Cliquer pour réactiver le son</string>
<string name="mute_button">Son activé. Cliquer pour mettre en silencieux</string>
<string name="search_button">Recherche d\'enregistrements locaux et distants</string>
<string name="nip05_verified">L\'adresse Nostr a été vérfiée</string>
<string name="nip05_failed">La vérification de l\'adresse Nostr a échouée</string>
<string name="nip05_checking">Vérification de l\'adresse Nostr</string>
<string name="select_deselect_all">Tout sélectionner/désélectionner</string>
<string name="default_relays">Défaut</string>
<string name="select_a_relay_to_continue">Sélectionner un relay pour continuer</string>
<string name="zap_forward_title">Transférer les Zaps à:</string>
<string name="zap_forward_explainer">Les clients compatibles transmettront des zaps sur l\'adresse LN ou le profil Utilisateur ci-dessous au lieu du vôtre.</string>
<string name="geohash_title">Révéler la localisation comme </string>
<string name="geohash_explainer">Ajoute un Geohash de votre emplacement au message. Le public saura que vous êtes à moins de 5km de l\'emplacement actuel</string>
<string name="add_sensitive_content_explainer">Ajoute un avertissement de contenu sensible avant de montrer votre contenu. C\'est idéal pour tout contenu NSFW ou contenu que certaines personnes peuvent trouver offensant ou dérangeant</string>
</resources>