Compare commits

..
93 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
Vitor Pamplona 5f85d8a97b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-01 11:10:10 -04:00
Vitor Pamplona eb77db0469 v0.72.0 2023-08-01 11:10:05 -04:00
Vitor PamplonaandGitHub a62445a52f Merge pull request #535 from velram/main
Translations - Tamil tech terms updated
2023-08-01 11:01:38 -04:00
Vitor Pamplona 553a1fe673 Avoids showing community definition types in the community feed. 2023-08-01 10:39:31 -04:00
Vitor Pamplona a50b0b7902 Moves navController back into Compose
Adds a listener for newIntents
Fixes the App losing the back stack of screens
2023-08-01 10:39:10 -04:00
VelandGitHub 23029a2022 Merge branch 'vitorpamplona:main' into main 2023-08-01 05:20:47 +05:30
Vitor Pamplona bd7156082b Fixing the correct EOSE cleanup 2023-07-31 18:06:16 -04:00
Vitor Pamplona bdd2a97ee3 formatting 2023-07-31 17:56:14 -04:00
Vitor Pamplona a2acec7900 Testing more aggressive memory management 2023-07-31 17:55:51 -04:00
Vitor Pamplona c4a9a2d557 Fixes the createAt issue on NIP24b 2023-07-31 16:46:58 -04:00
Vitor Pamplona 24481459a7 improves dump when pausing. 2023-07-31 14:19:40 -04:00
Vitor Pamplona 14d2d7439e Ignoring past version of addressable content in feeds 2023-07-31 11:14:15 -04:00
Vitor Pamplona dae8877983 Updates JNA dependency 2023-07-31 10:29:41 -04:00
Vitor Pamplona 81f2e72f0d Allows notification grouping by app 2023-07-31 10:29:22 -04:00
VelandGitHub a993e4b378 Merge pull request #1 from velram/tamil_translations
Tamil translations updated with Tamil tech terms
2023-07-31 09:08:45 +05:30
Coder de14d4e0f1 Tamil translations updated for various technical words 2023-07-30 22:40:18 -04:00
Vitor Pamplona fd58f063ac Removes unused values in strings 2023-07-30 18:15:26 -04:00
Vitor Pamplona 4593ce582d Removes leakcanary due to irrelevance 2023-07-30 18:15:08 -04:00
Vitor Pamplona 8d8fb8f5dc Updates to the latest libraries 2023-07-30 18:10:23 -04:00
Vitor Pamplona 3e4a4f7442 Testing if interning pubkeys and ids help save some memory. 2023-07-30 17:48:35 -04:00
Vitor Pamplona d5ad6b983a Reviews event-based filters. 2023-07-30 16:55:09 -04:00
Vitor Pamplona 30d31b35ab Filters the search to avoid getting 1000s of Wallet Connect and Nostr Connect messages a second. 2023-07-30 16:40:17 -04:00
Vitor Pamplona 18839b8853 Adds new kinds to the hashtag and geotag search 2023-07-30 16:39:12 -04:00
Vitor Pamplona e46a77f5ef Reverts to use the messageTitle as Group 2023-07-30 16:30:58 -04:00
Vitor Pamplona 2c8c0a8073 Improves search by looking into all downloaded events. 2023-07-30 16:28:30 -04:00
Coder 570bf1c2e7 Tamil translations based on references 2023-07-30 15:00:40 -04:00
Coder 1d1dc757ee Revert "Tamil translations updated - tech terms updated based on lexicon and refs from Tamil profs"
This reverts commit 50f0085118.
2023-07-30 14:34:31 -04:00
Coder 50f0085118 Tamil translations updated - tech terms updated based on lexicon and refs from Tamil profs 2023-07-30 13:55:58 -04:00
Vitor Pamplona 1edfc6bfbd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-07-30 13:06:58 -04:00
Vitor Pamplona 5596c54107 Requesting GiftWrapped messages in the DM filter 2023-07-30 13:06:53 -04:00
Vitor Pamplona ffe1d737fb Preparing to keep ChatMessages in the LocalCache. 2023-07-30 13:06:33 -04:00
Vitor PamplonaandGitHub 9b36b81721 Merge pull request #531 from KotlinGeekDev/grouped-notifications
Grouped notifications.
2023-07-30 13:03:50 -04:00
Vitor PamplonaandGitHub 800c1e1a44 Merge pull request #533 from davotoula/update_translations_cs_sv_de
Replace hard coded text with resource text and update translations
2023-07-30 13:01:41 -04:00
David Kaspar a011ef128a Replaced hard coded text with resource strings
Added new translations for cs/de/se
Corrected "occurred"
2023-07-30 17:46:46 +01:00
David Kaspar 2e8760abe5 Replaced hard coded text with resource strings
Added new translations for cs/de/se
Corrected "occurred"
2023-07-30 17:44:18 +01:00
David Kaspar 6acfaa97f3 Added missing translations cs/de/se 2023-07-30 16:55:59 +01:00
Vitor Pamplona 4426ccada7 Improving test 2023-07-29 18:35:52 -04:00
Vitor Pamplona da02f36663 Refactoring to clarify the use of crypto functions 2023-07-29 16:38:55 -04:00
Vitor Pamplona e240cd04d4 Final adjustments on the use of XChaCha 2023-07-29 16:00:13 -04:00
Vitor Pamplona 16a7fdffcf Correctly managing Shared Secrets 2023-07-29 15:29:51 -04:00
Vitor Pamplona 2fdb4e47b0 Cryptographic support for NIP24 2023-07-29 12:39:25 -04:00
Vitor Pamplona a316351ba7 Increases confidence of the Translator 2023-07-29 12:39:02 -04:00
KotlinGeekDev 492bc76957 Use different group keys for DMs and Zaps. 2023-07-29 17:04:55 +01:00
KotlinGeekDev e8170398e3 Use the same group key, so the system groups automatically. 2023-07-29 15:46:13 +01:00
Vitor Pamplona ff233c699b Separating the event creation into a Factory 2023-07-26 20:41:46 -04:00
Vitor Pamplona 0d1cbce5de Fixing the explainer text of the geohash addon 2023-07-25 21:49:55 -04:00
97 changed files with 2867 additions and 821 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
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.21" />
<option name="version" value="1.9.0" />
</component>
</project>
+16 -15
View File
@@ -1,7 +1,7 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
id 'org.jlleitschuh.gradle.ktlint' version "11.5.0"
id 'com.google.gms.google-services'
}
@@ -13,21 +13,21 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 266
versionName "0.71.0"
versionCode 269
versionName "0.72.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resConfigs("ar", "cs", "de", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW', 'ja']
}
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 {
@@ -37,7 +37,7 @@ android {
}
}
flavorDimensions "channel"
flavorDimensions = ["channel"]
productFlavors {
play {
@@ -73,7 +73,7 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.7"
kotlinCompilerExtensionVersion "1.5.1"
}
packagingOptions {
@@ -96,7 +96,7 @@ dependencies {
implementation "androidx.fragment:fragment-ktx:$fragment_version"
// Navigation
implementation("androidx.navigation:navigation-compose:$nav_version")
implementation "androidx.navigation:navigation-compose:$nav_version"
// Observe Live data as State
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
@@ -108,7 +108,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "net.engawapg.lib:zoomable:1.4.3"
@@ -153,7 +153,7 @@ dependencies {
implementation "io.github.url-detector:url-detector:0.1.23"
// For QR generation
implementation 'com.google.zxing:core:3.5.1'
implementation 'com.google.zxing:core:3.5.2'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown
@@ -194,14 +194,15 @@ dependencies {
// GeoHash
implementation 'com.github.drfonfon:android-kotlin-geohash:1.0'
// LibSodium for XChaCha encryption
implementation "com.goterl:lazysodium-android:5.1.0@aar"
implementation 'net.java.dev.jna:jna:5.13.0@aar'
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
+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
@@ -0,0 +1,102 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.KeyPair
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CryptoUtilsTest {
@Test
fun testGetPublicFromPrivateKey() {
val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
val publicKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey)
}
@Test
fun testSharedSecretCompatibilityWithCoracle() {
val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561"
val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f"
val key = CryptoUtils.getSharedSecretNIP24(privateKey = privateKey.hexToByteArray(), pubKey = publicKey.hexToByteArray())
assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey())
}
@Test
fun testSharedSecret() {
val sender = KeyPair()
val receiver = KeyPair()
val sharedSecret1 = CryptoUtils.getSharedSecretNIP24(sender.privKey!!, receiver.pubKey)
val sharedSecret2 = CryptoUtils.getSharedSecretNIP24(receiver.privKey!!, sender.pubKey)
assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey())
val secretKey1 = KeyPair(privKey = sharedSecret1)
val secretKey2 = KeyPair(privKey = sharedSecret2)
assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey())
assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey())
}
@Test
fun encryptDecryptNIP4Test() {
val msg = "Hi"
val privateKey = CryptoUtils.privkeyCreate()
val publicKey = CryptoUtils.pubkeyCreate(privateKey)
val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey)
val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey)
assertEquals(msg, decrypted)
}
@Test
fun encryptDecryptNIP24Test() {
val msg = "Hi"
val privateKey = CryptoUtils.privkeyCreate()
val publicKey = CryptoUtils.pubkeyCreate(privateKey)
val encrypted = CryptoUtils.encryptNIP24(msg, privateKey, publicKey)
val decrypted = CryptoUtils.decryptNIP24(encrypted, privateKey, publicKey)
assertEquals(msg, decrypted)
}
@Test
fun encryptSharedSecretDecryptNIP4Test() {
val msg = "Hi"
val privateKey = CryptoUtils.privkeyCreate()
val publicKey = CryptoUtils.pubkeyCreate(privateKey)
val sharedSecret = CryptoUtils.getSharedSecretNIP04(privateKey, publicKey)
val encrypted = CryptoUtils.encryptNIP04(msg, sharedSecret)
val decrypted = CryptoUtils.decryptNIP04(encrypted, sharedSecret)
assertEquals(msg, decrypted)
}
@Test
fun encryptSharedSecretDecryptNIP24Test() {
val msg = "Hi"
val privateKey = CryptoUtils.privkeyCreate()
val publicKey = CryptoUtils.pubkeyCreate(privateKey)
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, publicKey)
val encrypted = CryptoUtils.encryptNIP24(msg, sharedSecret)
val decrypted = CryptoUtils.decryptNIP24(encrypted, sharedSecret)
assertEquals(msg, decrypted)
}
}
@@ -0,0 +1,561 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.KeyPair
import com.vitorpamplona.amethyst.service.model.ChatMessageEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.Gossip
import com.vitorpamplona.amethyst.service.model.NIP24Factory
import com.vitorpamplona.amethyst.service.model.SealedGossipEvent
import com.vitorpamplona.amethyst.service.relays.Client
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class GiftWrapEventTest {
@Test()
fun testNip24Utils() {
val sender = KeyPair()
val receiver = KeyPair()
val message = "Hola, que tal?"
val events = NIP24Factory().createMsgNIP24(
message,
listOf(receiver.pubKey.toHexKey()),
sender.privKey!!
)
// Simulate Receiver
val eventsReceiverGets = events.filter { it.isTaggedUser(receiver.pubKey.toHexKey()) }
eventsReceiverGets.forEach {
val event = it.unwrap(receiver.privKey!!)
if (event is SealedGossipEvent) {
val innerData = event.unseal(receiver.privKey!!)
assertEquals(message, innerData?.content)
} else {
fail("Wrong Event")
}
}
// Simulate Sender
val eventsSenderGets = events.filter { it.isTaggedUser(sender.pubKey.toHexKey()) }
eventsSenderGets.forEach {
val event = it.unwrap(sender.privKey!!)
if (event is SealedGossipEvent) {
val innerData = event.unseal(sender.privKey!!)
assertEquals(message, innerData?.content)
} else {
fail("Wrong Event")
}
}
}
@Test()
fun testNip24UtilsForGroups() {
val sender = KeyPair()
val receiver1 = KeyPair()
val receiver2 = KeyPair()
val receiver3 = KeyPair()
val receiver4 = KeyPair()
val message = "Hola, que tal?"
val receivers = listOf(
receiver1,
receiver2,
receiver3,
receiver4
)
val events = NIP24Factory().createMsgNIP24(
message,
receivers.map { it.pubKey.toHexKey() },
sender.privKey!!
)
// Simulate Receiver
receivers.forEach { receiver ->
val eventsReceiverGets = events.filter { it.isTaggedUser(receiver.pubKey.toHexKey()) }
eventsReceiverGets.forEach {
val event = it.unwrap(receiver.privKey!!)
if (event is SealedGossipEvent) {
val innerData = event.unseal(receiver.privKey!!)
assertEquals(message, innerData?.content)
} else {
fail("Wrong Event")
}
}
}
// Simulate Sender
val eventsSenderGets = events.filter { it.isTaggedUser(sender.pubKey.toHexKey()) }
eventsSenderGets.forEach {
val event = it.unwrap(sender.privKey!!)
if (event is SealedGossipEvent) {
val innerData = event.unseal(sender.privKey!!)
assertEquals(message, innerData?.content)
} else {
fail("Wrong Event")
}
}
}
@Test()
fun testInternalsSimpleMessage() {
val sender = KeyPair()
val receiver = KeyPair()
val senderMessage = ChatMessageEvent.create(
msg = "Hi There!",
to = listOf(receiver.pubKey.toHexKey()),
privateKey = sender.privKey!!
)
// MsgFor the Receiver
val encMsgFromSenderToReceiver = SealedGossipEvent.create(
event = senderMessage,
encryptTo = receiver.pubKey.toHexKey(),
privateKey = sender.privKey!!
)
// Should expose sender
assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey.toHexKey())
// Should not expose receiver
assertTrue(encMsgFromSenderToReceiver.tags.isEmpty())
val giftWrapEventToReceiver = GiftWrapEvent.create(
event = encMsgFromSenderToReceiver,
recipientPubKey = receiver.pubKey.toHexKey()
)
// Should not be signed by neither sender nor receiver
assertNotEquals(giftWrapEventToReceiver.pubKey, sender.pubKey.toHexKey())
assertNotEquals(giftWrapEventToReceiver.pubKey, receiver.pubKey.toHexKey())
// Should not include sender as recipient
assertNotEquals(giftWrapEventToReceiver.recipientPubKey(), sender.pubKey.toHexKey())
// Should be addressed to the receiver
assertEquals(giftWrapEventToReceiver.recipientPubKey(), receiver.pubKey.toHexKey())
// MsgFor the Sender
val encMsgFromSenderToSender = SealedGossipEvent.create(
event = senderMessage,
encryptTo = sender.pubKey.toHexKey(),
privateKey = sender.privKey!!
)
// Should expose sender
assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey.toHexKey())
// Should not expose receiver
assertTrue(encMsgFromSenderToSender.tags.isEmpty())
val giftWrapEventToSender = GiftWrapEvent.create(
event = encMsgFromSenderToSender,
recipientPubKey = sender.pubKey.toHexKey()
)
// Should not be signed by neither the sender, not the receiver
assertNotEquals(giftWrapEventToSender.pubKey, sender.pubKey.toHexKey())
assertNotEquals(giftWrapEventToSender.pubKey, receiver.pubKey.toHexKey())
// Should not be addressed to the receiver
assertNotEquals(giftWrapEventToSender.recipientPubKey(), receiver.pubKey.toHexKey())
// Should be addressed to the sender
assertEquals(giftWrapEventToSender.recipientPubKey(), sender.pubKey.toHexKey())
// Done
println(senderMessage.toJson())
println(encMsgFromSenderToReceiver.toJson())
println(giftWrapEventToReceiver.toJson())
println(giftWrapEventToSender.toJson())
// Receiver's side
// Unwrapping
val unwrappedMsgForSenderBySender = giftWrapEventToSender.unwrap(sender.privKey!!)
val unwrappedMsgForReceiverBySender = giftWrapEventToReceiver.unwrap(sender.privKey!!)
assertNotNull(unwrappedMsgForSenderBySender)
assertNull(unwrappedMsgForReceiverBySender)
val unwrappedMsgForSenderByReceiver = giftWrapEventToSender.unwrap(receiver.privKey!!)
val unwrappedMsgForReceiverByReceiver = giftWrapEventToReceiver.unwrap(receiver.privKey!!)
assertNull(unwrappedMsgForSenderByReceiver)
assertNotNull(unwrappedMsgForReceiverByReceiver)
assertEquals(SealedGossipEvent.kind, unwrappedMsgForSenderBySender?.kind)
assertEquals(SealedGossipEvent.kind, unwrappedMsgForReceiverByReceiver?.kind)
assertTrue(unwrappedMsgForSenderBySender is SealedGossipEvent)
assertTrue(unwrappedMsgForReceiverByReceiver is SealedGossipEvent)
if (unwrappedMsgForSenderBySender is SealedGossipEvent &&
unwrappedMsgForReceiverByReceiver is SealedGossipEvent
) {
val unwrappedGossipToSenderByReceiver = unwrappedMsgForSenderBySender.unseal(receiver.privKey!!)
val unwrappedGossipToReceiverByReceiver = unwrappedMsgForReceiverByReceiver.unseal(receiver.privKey!!)
assertNull(unwrappedGossipToSenderByReceiver)
assertNotNull(unwrappedGossipToReceiverByReceiver)
val unwrappedGossipToSenderBySender = unwrappedMsgForSenderBySender.unseal(sender.privKey!!)
val unwrappedGossipToReceiverBySender = unwrappedMsgForReceiverByReceiver.unseal(sender.privKey!!)
assertNotNull(unwrappedGossipToSenderBySender)
assertNull(unwrappedGossipToReceiverBySender)
assertEquals("Hi There!", unwrappedGossipToReceiverByReceiver?.content)
assertEquals("Hi There!", unwrappedGossipToSenderBySender?.content)
} else {
fail()
}
}
@Test()
fun testInternalsGroupMessage() {
val sender = KeyPair()
val receiverA = KeyPair()
val receiverB = KeyPair()
val senderMessage = ChatMessageEvent.create(
msg = "Who is going to the party tonight?",
to = listOf(receiverA.pubKey.toHexKey(), receiverB.pubKey.toHexKey()),
privateKey = sender.privKey!!
)
val encMsgFromSenderToReceiverA = SealedGossipEvent.create(
event = senderMessage,
encryptTo = receiverA.pubKey.toHexKey(),
privateKey = sender.privKey!!
)
val encMsgFromSenderToReceiverB = SealedGossipEvent.create(
event = senderMessage,
encryptTo = receiverB.pubKey.toHexKey(),
privateKey = sender.privKey!!
)
val encMsgFromSenderToSender = SealedGossipEvent.create(
event = senderMessage,
encryptTo = sender.pubKey.toHexKey(),
privateKey = sender.privKey!!
)
// Should expose sender
assertEquals(encMsgFromSenderToReceiverA.pubKey, sender.pubKey.toHexKey())
// Should not expose receiver
assertTrue(encMsgFromSenderToReceiverA.tags.isEmpty())
// Should expose sender
assertEquals(encMsgFromSenderToReceiverB.pubKey, sender.pubKey.toHexKey())
// Should not expose receiver
assertTrue(encMsgFromSenderToReceiverB.tags.isEmpty())
// Should expose sender
assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey.toHexKey())
// Should not expose receiver
assertTrue(encMsgFromSenderToSender.tags.isEmpty())
val giftWrapEventForReceiverA = GiftWrapEvent.create(
event = encMsgFromSenderToReceiverA,
recipientPubKey = receiverA.pubKey.toHexKey()
)
val giftWrapEventForReceiverB = GiftWrapEvent.create(
event = encMsgFromSenderToReceiverB,
recipientPubKey = receiverB.pubKey.toHexKey()
)
// Should not be signed by neither sender nor receiver
assertNotEquals(giftWrapEventForReceiverA.pubKey, sender.pubKey.toHexKey())
assertNotEquals(giftWrapEventForReceiverA.pubKey, receiverA.pubKey.toHexKey())
assertNotEquals(giftWrapEventForReceiverA.pubKey, receiverB.pubKey.toHexKey())
// Should not include sender as recipient
assertNotEquals(giftWrapEventForReceiverA.recipientPubKey(), sender.pubKey.toHexKey())
// Should be addressed to the receiver
assertEquals(giftWrapEventForReceiverA.recipientPubKey(), receiverA.pubKey.toHexKey())
// Should not be signed by neither sender nor receiver
assertNotEquals(giftWrapEventForReceiverB.pubKey, sender.pubKey.toHexKey())
assertNotEquals(giftWrapEventForReceiverB.pubKey, receiverA.pubKey.toHexKey())
assertNotEquals(giftWrapEventForReceiverB.pubKey, receiverB.pubKey.toHexKey())
// Should not include sender as recipient
assertNotEquals(giftWrapEventForReceiverB.recipientPubKey(), sender.pubKey.toHexKey())
// Should be addressed to the receiver
assertEquals(giftWrapEventForReceiverB.recipientPubKey(), receiverB.pubKey.toHexKey())
val giftWrapEventToSender = GiftWrapEvent.create(
event = encMsgFromSenderToSender,
recipientPubKey = sender.pubKey.toHexKey()
)
// Should not be signed by neither the sender, not the receiver
assertNotEquals(giftWrapEventToSender.pubKey, sender.pubKey.toHexKey())
assertNotEquals(giftWrapEventToSender.pubKey, receiverA.pubKey.toHexKey())
assertNotEquals(giftWrapEventToSender.pubKey, receiverB.pubKey.toHexKey())
// Should not be addressed to the receiver
assertNotEquals(giftWrapEventToSender.recipientPubKey(), receiverA.pubKey.toHexKey())
assertNotEquals(giftWrapEventToSender.recipientPubKey(), receiverB.pubKey.toHexKey())
// Should be addressed to the sender
assertEquals(giftWrapEventToSender.recipientPubKey(), sender.pubKey.toHexKey())
println(senderMessage.toJson())
println(encMsgFromSenderToReceiverA.toJson())
println(encMsgFromSenderToReceiverB.toJson())
println(giftWrapEventForReceiverA.toJson())
println(giftWrapEventForReceiverB.toJson())
println(giftWrapEventToSender.toJson())
val unwrappedMsgForSenderBySender = giftWrapEventToSender.unwrap(sender.privKey!!)
val unwrappedMsgForReceiverBySenderA = giftWrapEventForReceiverA.unwrap(sender.privKey!!)
val unwrappedMsgForReceiverBySenderB = giftWrapEventForReceiverB.unwrap(sender.privKey!!)
assertNotNull(unwrappedMsgForSenderBySender)
assertNull(unwrappedMsgForReceiverBySenderA)
assertNull(unwrappedMsgForReceiverBySenderB)
val unwrappedMsgForSenderByReceiverA = giftWrapEventToSender.unwrap(receiverA.privKey!!)
val unwrappedMsgForReceiverAByReceiverA = giftWrapEventForReceiverA.unwrap(receiverA.privKey!!)
val unwrappedMsgForReceiverBByReceiverA = giftWrapEventForReceiverB.unwrap(receiverA.privKey!!)
assertNull(unwrappedMsgForSenderByReceiverA)
assertNotNull(unwrappedMsgForReceiverAByReceiverA)
assertNull(unwrappedMsgForReceiverBByReceiverA)
val unwrappedMsgForSenderByReceiverB = giftWrapEventToSender.unwrap(receiverB.privKey!!)
val unwrappedMsgForReceiverAByReceiverB = giftWrapEventForReceiverA.unwrap(receiverB.privKey!!)
val unwrappedMsgForReceiverBByReceiverB = giftWrapEventForReceiverB.unwrap(receiverB.privKey!!)
assertNull(unwrappedMsgForSenderByReceiverB)
assertNull(unwrappedMsgForReceiverAByReceiverB)
assertNotNull(unwrappedMsgForReceiverBByReceiverB)
assertEquals(SealedGossipEvent.kind, unwrappedMsgForSenderBySender?.kind)
assertEquals(SealedGossipEvent.kind, unwrappedMsgForReceiverAByReceiverA?.kind)
assertEquals(SealedGossipEvent.kind, unwrappedMsgForReceiverBByReceiverB?.kind)
assertTrue(unwrappedMsgForSenderBySender is SealedGossipEvent)
assertTrue(unwrappedMsgForReceiverAByReceiverA is SealedGossipEvent)
assertTrue(unwrappedMsgForReceiverBByReceiverB is SealedGossipEvent)
if (unwrappedMsgForSenderBySender is SealedGossipEvent &&
unwrappedMsgForReceiverAByReceiverA is SealedGossipEvent &&
unwrappedMsgForReceiverBByReceiverB is SealedGossipEvent
) {
val unwrappedGossipToSenderByReceiverA = unwrappedMsgForSenderBySender.unseal(receiverA.privKey!!)
val unwrappedGossipToReceiverAByReceiverA = unwrappedMsgForReceiverAByReceiverA.unseal(receiverA.privKey!!)
val unwrappedGossipToReceiverBByReceiverA = unwrappedMsgForReceiverBByReceiverB.unseal(receiverA.privKey!!)
assertNull(unwrappedGossipToSenderByReceiverA)
assertNotNull(unwrappedGossipToReceiverAByReceiverA)
assertNull(unwrappedGossipToReceiverBByReceiverA)
val unwrappedGossipToSenderByReceiverB = unwrappedMsgForSenderBySender.unseal(receiverB.privKey!!)
val unwrappedGossipToReceiverAByReceiverB = unwrappedMsgForReceiverAByReceiverA.unseal(receiverB.privKey!!)
val unwrappedGossipToReceiverBByReceiverB = unwrappedMsgForReceiverBByReceiverB.unseal(receiverB.privKey!!)
assertNull(unwrappedGossipToSenderByReceiverB)
assertNull(unwrappedGossipToReceiverAByReceiverB)
assertNotNull(unwrappedGossipToReceiverBByReceiverB)
val unwrappedGossipToSenderBySender = unwrappedMsgForSenderBySender.unseal(sender.privKey!!)
val unwrappedGossipToReceiverABySender = unwrappedMsgForReceiverAByReceiverA.unseal(sender.privKey!!)
val unwrappedGossipToReceiverBBySender = unwrappedMsgForReceiverBByReceiverB.unseal(sender.privKey!!)
assertNotNull(unwrappedGossipToSenderBySender)
assertNull(unwrappedGossipToReceiverABySender)
assertNull(unwrappedGossipToReceiverBBySender)
assertEquals("Who is going to the party tonight?", unwrappedGossipToReceiverAByReceiverA?.content)
assertEquals("Who is going to the party tonight?", unwrappedGossipToReceiverBByReceiverB?.content)
assertEquals("Who is going to the party tonight?", unwrappedGossipToSenderBySender?.content)
} else {
fail()
}
}
@Test
fun testCaseFromAmethyst1() {
val json = """
{
"content":"{\"ciphertext\":\"AaTN5Mt7AOeMosjHeLfai89kmvW/qJ7W2VMttAwuh6hwRGV+ylJhpDbdVRhVmkCotbDjBgS6xioLrSDcdSngFOiVMHS5dTAP0MkQv09aZlBh/NgdmyfHHd24YlHPkDuF5Yb4Vmz7kq/vmjsNZvDrTen3TG2DcEoTV9GKexdMEqyBA4LsB2DLnWfpvOi0olDkGjPGSteTaU1nCdOtN8knoEKumrxwevvbygKphorvKX/j3ojMMb0AceJM6Cr6TRIvSsQnKGEv5V8qbC/uIrQoH3N108Fd/2SY2MWuyLKRnuak9F/w82MV13elq8ngyjcktLYM5yrPg5nrxZlyJsV8D7V/g/bvhoL+UmWe0XoCR5LXzy77SfIkgA1ePKEfGp5sD2CVIzXt9zHdFwGxAKZuyB4qwrRaAFrS2xx+Bw4nnEmF6V9NhfheSCmGzTILuTePx4ubvnYw/j8Hmqd6UvM3DBNnlJ3D6po0blirfWvMe/ea+Em4CMXfq8Iq+7r4gRx8azADygKeJ+C89GTBEvS9EvgrXCVfTMVTcFc44YAZhekOqYY1BOZgfxIV4gUiJfpMMd4B9MQv/tmnewrpTsq1reSQQcEW/mXT2cnMeCZbAIJSPg8usZ30QlrH+np+YSzFKWYDP1kThcV0ElEE2Ne8KaUUFIRE5KmhBQc/qtORefCpne5s7V7J5vLjT5rinsDzzENB1XVlmY1Icx42raP5tGAL1gOK5gRHLvtcgFQR3WcDRYaNqELiYxx41j9w9lz5e00Ttla255rZkb760KSLaBFBss6wYGiYCabVgtBNpkExpCFPPEd5eAZa5rNK2QrnojYsdxEnlicF6A+zSChLy/TbzxYwyQywDfoF9F8kBakPZkAhsciQViCii2KlieRq4OgJFZGndmnS82hyPqsoJIm22vWr1iqMvSBHo/9cLj/r+lfmGVOdgM62JHckPZjOLS0QWIb9gQiT+zXZG22+eZElMYbGXVpR1dyMaQtde8ivEVVLas6kMCVKaDTHEFglaCBXjJ3RNJv73HsG1kb0rMmOj8ltbBakjHpv7M59amavuu6SReYt\",\"nonce\":\"6anNjUdNwW6MNfoKzRZcz1R09N1h8G4L\",\"v\":1}",
"created_at":1690660515,
"id":"d90739741c2f5a8c1a03aab5dc219c0b708ed6b0566044495731cd0307cf19a5",
"kind":1059,
"pubkey":"a79b7162f8ebb9c9f7aa65a48977ab7f32aa097520bc543e4d625812154ff6af",
"sig":"9b012504e779632a2a1f55562fa9a85f8ae6245cbc149b83d25b2971249053abc77f65cc068e5d025b871d743678265fede70de4eaf5af642e675a5b6210077d",
"tags":[
[
"p",
"c55f0b0cb4dd180dd4395867b28dd4c208b709144a69fb7c31a46c369a5ad1c6"
]
]
}
""".trimIndent()
val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70"
val gossip = unwrapUnsealGossip(json, privateKey)
assertNotNull(gossip)
assertEquals("Hola, que tal?", gossip?.content)
}
@Test
fun testCaseFromAmethyst2() {
val json = """
{
"content":"{\"ciphertext\":\"Zb0ZNYAcDG5y7BiCWgbxY/i7rN7TxPwr3Oaste6em4VcetuenaMu2SyH6OuCCxxmIa7kFennJD8ZCrev0086azsPNutl9I6OCoOfDQb2GoFaLoJAkE/FuW0uEoEJuN72KsKj05HEjOM6nqL2KiW0pxTCNmlGpweMwpXQdm2ItWkybNpq8+b4NJUDee2czBUd9Kr2ELbPISTYzA17z1IzPXGQw8c73NL+QX9I/QZjM/agqX2x5q11SU52xiRyVd9zHf7TMctZI4QEsqDB6xi54D1bAeZlMhVdcpQRpGDfqRz3KXFlhB3Bwdc8GLgY0aLTn6tJs4qrHP3mQkxFYk0mju0afoc0rloMEUHcBVtM18S9OrTPqfmSqFTQsjaT8g+PkmeiLBo1sXsMCS62w0abSZD9OzQtciMz70ZpcWoLjx5f8panjFClvg4tJ8czMURIHM/IFS1uKAUHBArGN8QpCw8MXQBblpyLDiEkFcSX334Zdps0OIw4z328JSdeejyRh4ks+NHDt9FcjC4iicEqfEh8OTkXuKqEAVkRyfAioNQxWQPnXDzMX0Q+BXvKzBA7NaEBDpbV36H/KnrpBBQwokV9/Byb6Seh3g6GSqRAWD3U6Nk2aBMXkD0xY8vnIqMckBeYHxn8BW7k1FdXFC9lE5xCxWZHkmksJ4f0NVaF37O6d8qOe6RK7bfUeF8/SouJEu+eEX1f4KCMboslwkdk8QA8bThGcRGn8GQBMrPKrpZwHYNyyH8jwt9pywigXJejRLDDnDp3FH/3dbZy5CfuNH6KGydf/O5xx1r316so1UPO1mL5LHJUFZVIaMaMMUsgq12gpI0lLEh5NJPpsi9e3ibkzEZGf7FlAJjJQURbQ8xacN7R+w3GWKbJNHiQbUZ2lXo6fwz33t0DrSqEW970yWPHlqxcpd27EI+qqb5IqfklQZ3RObZZBhzDvImaCPG+U7SmgLhPxnilpGjd5lw/ttiqJhPG9mYFMf1eJXSG+Q9VVkGzN7jxXYtx0q0WGjVq98ZGv5RSnF1d9+QVGCd1fiPS3rsaWdYWly8l0y2quYObJ6Mv3Wh3\",\"nonce\":\"/Q2UTTjVZthm/atcCuDjU1e4reF+ZSgZ\",\"v\":1}",
"created_at":1690660515,
"id":"087d9627d63135a5050758a69222e566c86702e930c9905f0b93ccd6bebeca3f",
"kind":1059,
"pubkey":"e59c00796ae2aa9077fc8bcd57fe8d32c0fc363f7c8b93d055c70804ffff3772",
"sig":"807cb641c314ca6910aaeefadcf87d859137520be1039eb40e39832ed59d456fdd800c5f88bba09e1b395ee90c66d5330847bdd010b63be9919bf091adbc2c2a",
"tags":[
[
"p",
"f85f315c06aaf19c2b30a96ca80d9644720655ee8d3ec43b84657a7c98f36a23"
]
]
}
""".trimIndent()
val privateKey = "409ff7654141eaa16cd2161fe5bd127aeaef71f270c67587474b78998a8e3533"
val gossip = unwrapUnsealGossip(json, privateKey)
assertNotNull(gossip)
assertEquals("Hola, que tal?", gossip?.content)
}
@Test
fun testDecryptFromCoracle() {
val json = """
{
"content": "{\"ciphertext\":\"fo0/Ywyfu86cXKoOOeFFlMRv+LYM6GJUL+F/J4ARv6EcAufOZP46vimlurPBLPjNgzuGemGjlTFfC3vtq84AqIsqFo3dqKunq8Vp+mmubvxIQUDzOGYvM0WE/XOiW5LEe3U3Vq399Dq07xRpXELcp4EZxGyu4Fowv2Ppb4SKpH8g+9N3z2+bwYcSxBvI6SrL+hgmVMrRlgvsUHN1d53ni9ehRseBqrLj/DqyyEiygsKm6vnEZAPKnl1MrBaVOBZmGsyjAa/G4SBVVmk78sW7xWWvo4cV+C22FluDWUOdj/bYabH4aR4scnBX3GLYqfLuzGnuQlRNsb5unXVX41+39uXzROrmNP6iYVyYxy5tfoyN7PPZ4osoKpLDUGldmXHD6RjMcAFuou4hXt2JlTPmXpj/x8qInXId5mkmU4nTGiasvsCIpJljbCujwCjbjLTcD4QrjuhMdtSsAzjT0CDv5Lmc632eKRYtDu/9B+lkqBBkp7amhzbqp8suNTnybkvbGFQQGEQnsLfNJw/GGopAuthfi8zkTgUZR/LxFR7ZKAX73G+5PQSDSjPuGH/dQEnsFo45zsh1Xro8SfUQBsPphbX2GS31Lwu5vA30O922T4UiWuU+EdNgZR0JankQ5NPgvr1uS56C3v84VwdrNWQUCwC4eYJl4Mb/OdpEy9qwsisisppq6uuzxmxd1qx3JfocnGsvB7h2g2sG+0lyZADDSobOEZEKHaBP3w+dRcJW9D95EmzPym9GO0n+33OfqFQbda7G0rzUWfPDV0gXIuZcKs/HmDqepgIZN8FG7JhRBeAv0bCbKQACre0c8tzVEn5yCYemltScdKop3pC/r6gH50jRhAlFAiIKx8R+XwuMmJRqOcH4WfkpZlfVU85/I0XJOCHWKk6BnJi/NPP9zYiZiJe+5LecqMUVjtO0YAlv138+U/3FIT/anQ4H5bjVWBZmajwf\",\"nonce\":\"Mv70S6jgrs4D1rlqV9b5DddiymGAcVVe\",\"v\":1}",
"created_at": 1690528373,
"id": "6b108e4236c3c338236ee589388ce0f91f921e1532ae52e75d1d2add6f8e691a",
"kind": 1059,
"pubkey": "627dc0248335e2bf9adac14be9494139ebbeb12c422d7df5b0e3cd72d04c209c",
"sig": "be11da487196db298e4ffb7a03e74176c37441c88b39c95b518fadce6fd02f23c58b2c435ca38c24d512713935ab719dae80bf952267630809e1f84be8e95174",
"tags": [
[
"p",
"e7764a227c12ac1ef2db79ae180392c90903b2cec1e37f5c1a4afed38117185e"
]
],
"seenOn": [
"wss://relay.damus.io/"
]
}
""".trimIndent()
val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771"
val gossip = unwrapUnsealGossip(json, privateKey)
assertNotNull(gossip)
assertEquals("test", gossip?.content)
}
@Test
fun testFromCoracle2() {
val json = """
{
"content": "{\"ciphertext\":\"Hn/dHo/I8Qk6QWWAiKyo/SfKJqQfHdV0O5tMmgqMyfHrsFoDY6IhGQP2EgCJ/6HsNQyO/8EMAmLW8w0PbDKlBKYGKGpaMwCA6B1r0rLjvu+149RJZuggRNm9rd7tNVNkNs38iqt1KYD++bohePm52q+VhAQikbX2gTONV82ROwZylAg9vjvMnYkDt45g6N97s9FRB6V7YMiUEtJnneMixa6klucpUuenQ4569tyt5vnUMD2VNhKYCc2jit2hf7k0DIhvZrVC3OdopUvxIuYYWr3r7XpuEB3HJ6Ji3ajHPzgGeFcItBR7uKZ9s6XU34F3keyZbxrv3yWHFM5NrOctAdZexSGpqWRW93M0KZUAp9HgQh3YzMLl8xt0mcrVywCgjU6Kx8IwkI0bjPU+Am8acY3cItted6hZQ4Vy1xFITdKVfPWDl3Ab59iBg9+IkY5C31wqsKPgPVVycwQE6UpaGW74gy3qZshwyoo01owvEIbVvrSJWXH7EUVvndDPvUbo+f+EVa84IEwVjPmY2oR7VsxVfqRBdmPg23OSw/9rzVybmruqaQHd3xrTTEcnG0qBc/ugCXsiuILTeScOovEnqIlKKK3KB36jMtdScdJB+b4YrzJInY1AvqU7IAgqe0vmo1LdbMtj7kjuxkXJhhQsunAbTvPigTrsOfJ08P9l7r/95kpxudgagEaW7XAjYVfLphseJT3Iy1IuQEyG5sshQ+pl/CYvkGide7ykHwm9pjSBVkD9Mdcn5X6lSnLNJEcwY43pz43r6Kq3L09qneILY3DSKyQ16Zcu1MiAMAM5r6JGvpAHqcMmixi9ORuiryjteTmY4L0vI7b3W/0RSUblXxUrb8IpeysBrFmiKJBgCoU0r/D/8tgR+Eewyp1qxKI4SfKG5GFH40zZ2oVvKyoHAR4x1oVDp/MttcnxkzAsCFL6QuJC9A/vImjsumpmYB/EChcZCOAsfqkuzH4VSjZx\",\"nonce\":\"K537d+7m5tUcXZfkr3Qk2J2G86vdBMmY\",\"v\":1}",
"created_at": 1690655012,
"id": "c4f97c6332b0a63912c44c9e1f8c7b23581dc67a8489ec1522ec205fea7133db",
"kind": 1059,
"pubkey": "8def03a22b1039256a3883d46c7ccd5562f61743100db401344284547de7ec61",
"sig": "25dcf24bdda99c04abc72274d9f7a30538a4a00a70ac4b39db4082b73823979858df93cd649c25edfb759857eac46ed70bb9ad0598f2e011d733a5a382bc4def",
"tags": [
[
"p",
"e7764a227c12ac1ef2db79ae180392c90903b2cec1e37f5c1a4afed38117185e"
]
]
}
""".trimIndent()
val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771"
val gossip = unwrapUnsealGossip(json, privateKey)
assertEquals("asdfasdfasdf", gossip?.content)
assertEquals(1690659269L, gossip?.createdAt)
assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", gossip?.id)
assertEquals(14, gossip?.kind)
assertEquals("subject", gossip?.tags?.firstOrNull()?.get(0))
assertEquals("test", gossip?.tags?.firstOrNull()?.get(1))
}
@Test
fun testFromCoracle3() {
val json = """
{
"content": "{\"ciphertext\":\"PGCodiacmCB/sClw0C6DQRpP/XIfNAKCVZdKLQpOqytgbFryhs9Z+cPldq2FajXzxs3jQsF7/EVQyWpuV8pXetvFT9tvzjg4Xcm7ZcooLUnAeAo2xZNcJontN4cGubuDqKuXy5n59yXP1fIxfnJxRTRRdCZ2edhsKeNR5NSByUi+StjV10rnfHt8AhZCpiXiZ/giTOsC4wdaeONPgMzMeljaJWLvl6n11VjmXhkx1mXIQt43CNB1hIqO3p89Mbd9p+nlLrOsR+Xs0TB4DCh4XTPbvgf7B7Z+PgOfl3GZfJy9x6TciLcF4E3Ba1zrPe4f79czCIEiJ1yrIKrzzYvv+it35DZQ8fgveFXpyHnNL29hml8PNjyOsFbCHVYLMGw88evI5PijOcpe1TtdoioX8kX5kVEQSKJXuoSjTorvbRPCgGzaa1m0J0uTpzri5VD22a/Jh2CcAnubg6w4JDdUWCogdSV3NqiJllo7ZF7WnZ3apPdRD23MEfphVBJrcLBUNlmwajnY5IvVTKTkZOP50r9dBapvMWXIo6M6zhy/5vVWJz57863pelYCRG4upaXZuNK9sMBtbiphxmFR83i8RML8KN8Q391Cd/xBN7TxJNo5p2YU25VeGZUAmHY8DYlMQDm8Br0nStAXp3T+DzTRL8FTECa8DJV+KTAPoCxqhv3B28Ehr0XAP75CsHoLU00G48cR7h3vQ0CnfKh6KXU6nnDA5OWfpMYpirACCpsnpSD0OaCQ3gkQp3zZNMS3HcOpnPK/IY7R0esbzgAkvNhkyxaIfPDdf+eRUSOA9+2Ji28MwjjY8Dw3SLdUqCOzIDjQeR/T5oNmaQJm3lZ8G0FxxC6ejD4VJX/NI/x+STeB9jWHWmHZvqKzV6JHNh6qmZb6TKSIPOHpafWFoeJFOmiiigf46sju9vRXmVEAx59HXWnvnvCBNJg877yCMulB6xyQuSdVDuotQU4tQZwCKedTHJ6GqjesM98UlJrDtdWQURwwW1qc7N8tS6PukmUVEf0jmbIWVIBmUlkcVuiSs1g1h1kjt8c4MnGTz3CSgpOd1MqxLrl9WwrTqM+YnE+yeZYUjFoewyKZIQ==\",\"nonce\":\"OdCZczJiUGR4bOGIElQ4UUH4dQmG5U/3\",\"v\":1}",
"kind": 1059,
"created_at": 1690772945,
"pubkey": "e01475e87896800b7285eb0daf263c59f811c8fc5bc8daa105d2c98b6d7c4952",
"tags": [
[
"p",
"b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78"
]
],
"id": "d9fc85ece892ce45ffa737b3ddc0f8b752623181d75363b966191f8c03d2debe",
"sig": "1b20416b83f4b5b8eead11e29c185f46b5e76d1960e4505210ddd00f7a6973cc11268f52a8989e3799b774d5f3a55db95bed4d66a1b6e88ab54becec5c771c17"
}
""".trimIndent()
val privateKey = "7dd22cafc512c0bc363a259f6dcda515b13ae3351066d7976fd0bb79cbd0d700"
val gossip = unwrapUnsealGossip(json, privateKey)
assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", gossip?.id)
assertEquals("Howdy", gossip?.content)
assertEquals(1690833960L, gossip?.createdAt)
assertEquals(14, gossip?.kind)
assertEquals("p", gossip?.tags?.firstOrNull()?.get(0))
assertEquals("b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78", gossip?.tags?.firstOrNull()?.get(1))
assertEquals("subject", gossip?.tags?.getOrNull(1)?.get(0))
assertEquals("Stuff", gossip?.tags?.getOrNull(1)?.get(1))
}
fun unwrapUnsealGossip(json: String, privateKey: HexKey): Gossip? {
val pkBytes = privateKey.hexToByteArray()
val wrap = Event.fromJson(json, Client.lenient) as GiftWrapEvent
wrap.checkSignature()
assertEquals(CryptoUtils.pubkeyCreate(pkBytes).toHexKey(), wrap.recipientPubKey())
val event = wrap.unwrap(pkBytes)
assertNotNull(event)
return if (event is SealedGossipEvent) {
return event.unseal(pkBytes)
} else {
println(event?.toJson())
fail("Event is not a Sealed Gossip")
null
}
}
}
@@ -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()
@@ -117,7 +121,32 @@ object ServiceManager {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
// LocalCache.pruneNonFollows(it)
LocalCache.pruneRepliesAndReactions(it)
}
}
}
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()) {
@@ -1110,6 +1110,57 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: ChatMessageEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
private fun consume(event: SealedGossipEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
private fun consume(event: GiftWrapEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: LnZapPaymentRequestEvent) {
// Does nothing without a response callback.
}
@@ -1185,13 +1236,12 @@ object LocalCache {
}
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is ChannelMessageEvent && it.event?.content()?.contains(text, true) ?: false) ||
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true) ||
it.idNote().startsWith(text, true)
} + addressables.values.filter {
(it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false ||
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true)
}
@@ -1222,6 +1272,10 @@ object LocalCache {
it.value.clearLive()
}
addressables.forEach {
it.value.clearLive()
}
users.forEach {
it.value.clearLive()
}
@@ -1233,6 +1287,8 @@ object LocalCache {
channels.forEach { it ->
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
@@ -1241,17 +1297,84 @@ object LocalCache {
it.replyTo?.forEach { _ ->
it.removeReply(it)
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 100 || it.value.notes.size > 100) {
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
}
}
users.forEach { userPair ->
userPair.value.privateChatrooms.values.map {
val toBeRemoved = it.pruneMessagesToTheLatestOnly()
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept")
}
}
}
}
fun pruneRepliesAndReactions(account: Account) {
checkNotInMainThread()
val user = account.userProfile()
val toBeRemoved = notes.filter {
(
(it.value.event is TextNoteEvent && !it.value.isNewThread()) ||
it.value.event is ReactionEvent || it.value.event is LnZapEvent || it.value.event is LnZapRequestEvent ||
it.value.event is ReportEvent || it.value.event is GenericRepostEvent
) &&
it.value.liveSet?.isInUse() != true && // don't delete if observing.
it.value.author != user && // don't delete if it is the logged in account
it.value.event?.isTaggedUser(user.pubkeyHex) != true // don't delete if it's a notification to the logged in user
}.values
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.clearEOSE() // allows reloading of these events
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
}
fun pruneHiddenMessages(account: Account) {
checkNotInMainThread()
val childrenToBeRemoved = mutableListOf<Note>()
val toBeRemoved = account.hiddenUsers.map { userHex ->
(
notes.values.filter {
@@ -1273,11 +1396,30 @@ object LocalCache {
}
notes.remove(it.idHex)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note ->
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull { getUserIfExists(it.key)?.removeZap(note) }
}
notes.remove(note.idHex)
}
}
fun pruneContactLists(userAccount: Account) {
checkNotInMainThread()
@@ -1325,6 +1467,7 @@ object LocalCache {
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ChatMessageEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
@@ -1337,10 +1480,12 @@ object LocalCache {
is DeletionEvent -> consume(event)
is EmojiPackEvent -> consume(event)
is EmojiPackSelectionEvent -> consume(event)
is SealedGossipEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is GiftWrapEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay)
@@ -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,27 +151,63 @@ open class Note(val idHex: String) {
}
fun removeReply(note: Note) {
replies = replies - note
liveSet?.replies?.invalidateData()
if (note in replies) {
replies = replies - note
liveSet?.replies?.invalidateData()
}
}
fun removeBoost(note: Note) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
if (note in boosts) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
}
}
fun removeAllChildNotes(): List<Note> {
val toBeRemoved = replies +
reactions.values.flatten() +
boosts +
reports.values.flatten() +
zaps.keys +
zaps.values.filterNotNull() +
zapPayments.keys +
zapPayments.values.filterNotNull()
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 = listOf<String>()
lastReactionsDownloadTime = emptyMap()
liveSet?.replies?.invalidateData()
liveSet?.reactions?.invalidateData()
liveSet?.boosts?.invalidateData()
liveSet?.reports?.invalidateData()
liveSet?.zaps?.invalidateData()
return toBeRemoved
}
fun removeReaction(note: Note) {
val tags = note.event?.tags() ?: emptyList()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
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()
}
}
}
}
@@ -277,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)
@@ -289,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)
@@ -382,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> {
@@ -547,13 +583,17 @@ open class Note(val idHex: String) {
}
replyTo = null
replies = emptySet()
replies = emptyList()
reactions = emptyMap()
boosts = emptySet()
boosts = emptyList()
reports = emptyMap()
zaps = emptyMap()
}
fun clearEOSE() {
lastReactionsDownloadTime = emptyMap()
}
var liveSet: NoteLiveSet? = null
fun live(): NoteLiveSet {
@@ -613,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
}
@@ -1,13 +1,17 @@
package com.vitorpamplona.amethyst.model
object TimeUtils {
const val fiveMinutes = 60 * 5
const val oneHour = 60 * 60
const val oneDay = 24 * 60 * 60
const val oneMinute = 60
const val fiveMinutes = 5 * oneMinute
const val oneHour = 60 * oneMinute
const val eightHours = 8 * oneHour
const val oneDay = 24 * oneHour
const val oneWeek = 7 * oneDay
fun now() = System.currentTimeMillis() / 1000
fun fiveMinutesAgo() = now() - fiveMinutes
fun oneHourAgo() = now() - oneHour
fun oneDayAgo() = now() - oneDay
fun eightHoursAgo() = now() - (oneHour * 8)
fun eightHoursAgo() = now() - eightHours
fun oneWeekAgo() = now() - oneWeek
}
@@ -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")
@@ -137,6 +137,16 @@ class User(val pubkeyHex: String) {
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zapRequestOrZapEvent in zaps.keys) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.zaps?.invalidateData()
} else if (zapRequestOrZapEvent in zaps.values) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.zaps?.invalidateData()
}
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
@@ -383,6 +393,22 @@ class Chatroom() {
roomMessages = roomMessages + msg
}
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
return toRemove
}
}
@Stable
@@ -449,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)
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.utils.Key
import fr.acinq.secp256k1.Hex
import fr.acinq.secp256k1.Secp256k1
import java.security.MessageDigest
@@ -28,36 +30,6 @@ object CryptoUtils {
fun sign(data: ByteArray, privKey: ByteArray): ByteArray =
secp256k1.signSchnorr(data, privKey, null)
fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
val sharedSecret = getSharedSecret(privateKey, pubKey)
return encrypt(msg, sharedSecret)
}
fun encrypt(msg: String, sharedSecret: ByteArray): String {
val iv = ByteArray(16)
random.nextBytes(iv)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
val ivBase64 = Base64.getEncoder().encodeToString(iv)
val encryptedMsg = cipher.doFinal(msg.toByteArray())
val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg)
return "$encryptedMsgBase64?iv=$ivBase64"
}
fun decrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
val sharedSecret = getSharedSecret(privateKey, pubKey)
return decrypt(msg, sharedSecret)
}
fun decrypt(msg: String, sharedSecret: ByteArray): String {
val parts = msg.split("?iv=")
val iv = parts[1].run { Base64.getDecoder().decode(this) }
val encryptedMsg = parts.first().run { Base64.getDecoder().decode(this) }
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
return String(cipher.doFinal(encryptedMsg))
}
fun verifySignature(
signature: ByteArray,
hash: ByteArray,
@@ -74,14 +46,87 @@ object CryptoUtils {
/**
* @return 32B shared secret
*/
fun getSharedSecret(privateKey: ByteArray, pubKey: ByteArray): ByteArray =
fun getSharedSecretNIP04(privateKey: ByteArray, pubKey: ByteArray): ByteArray =
secp256k1.pubKeyTweakMul(Hex.decode("02") + pubKey, privateKey).copyOfRange(1, 33)
fun encryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
val sharedSecret = getSharedSecretNIP04(privateKey, pubKey)
return encryptNIP04(msg, sharedSecret)
}
fun encryptNIP04(msg: String, sharedSecret: ByteArray): String {
val iv = ByteArray(16)
random.nextBytes(iv)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
val ivBase64 = Base64.getEncoder().encodeToString(iv)
val encryptedMsg = cipher.doFinal(msg.toByteArray())
val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg)
return "$encryptedMsgBase64?iv=$ivBase64"
}
fun decryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
val sharedSecret = getSharedSecretNIP04(privateKey, pubKey)
return decryptNIP04(msg, sharedSecret)
}
fun decryptNIP04(msg: String, sharedSecret: ByteArray): String {
val parts = msg.split("?iv=")
val iv = parts[1].run { Base64.getDecoder().decode(this) }
val encryptedMsg = parts.first().run { Base64.getDecoder().decode(this) }
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
return String(cipher.doFinal(encryptedMsg))
}
fun encryptNIP24(msg: String, privateKey: ByteArray, pubKey: ByteArray): EncryptedInfo {
val sharedSecret = getSharedSecretNIP24(privateKey, pubKey)
return encryptNIP24(msg, sharedSecret)
}
fun encryptNIP24(msg: String, sharedSecret: ByteArray): EncryptedInfo {
val nonce = ByteArray(24)
random.nextBytes(nonce)
val cipher = SodiumAndroid().cryptoStreamXChaCha20Xor(
messageBytes = msg.toByteArray(),
nonce = nonce,
key = Key.fromBytes(sharedSecret)
)
val cipherBase64 = Base64.getEncoder().encodeToString(cipher)
val nonceBase64 = Base64.getEncoder().encodeToString(nonce)
return EncryptedInfo(
ciphertext = cipherBase64,
nonce = nonceBase64,
v = Nip44Version.XChaCha20.versionCode
)
}
fun decryptNIP24(encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String? {
val sharedSecret = getSharedSecretNIP24(privateKey, pubKey)
return decryptNIP24(encryptedInfo, sharedSecret)
}
fun decryptNIP24(encryptedInfo: EncryptedInfo, sharedSecret: ByteArray): String? {
return SodiumAndroid().cryptoStreamXChaCha20Xor(
messageBytes = Base64.getDecoder().decode(encryptedInfo.ciphertext),
nonce = Base64.getDecoder().decode(encryptedInfo.nonce),
key = Key.fromBytes(sharedSecret)
)?.decodeToString()
}
/**
* @return 32B shared secret
*/
fun getSharedSecretNIP24(privateKey: ByteArray, pubKey: ByteArray): ByteArray =
sha256(secp256k1.pubKeyTweakMul(Hex.decode("02") + pubKey, privateKey).copyOfRange(1, 33))
}
fun Int.toByteArray(): ByteArray {
val bytes = ByteArray(4)
(0..3).forEach {
bytes[3 - it] = ((this ushr (8 * it)) and 0xFFFF).toByte()
}
return bytes
data class EncryptedInfo(val ciphertext: String, val nonce: String, val v: Int)
enum class Nip44Version(val versionCode: Int) {
Reserved(0),
XChaCha20(1)
}
@@ -6,7 +6,6 @@ import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.os.HandlerThread
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
@@ -53,9 +52,6 @@ class LocationUtil(context: Context) {
locationStateFlow.value = location
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
}
override fun onProviderEnabled(provider: String) {
providerState.value = true
}
@@ -67,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 {
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -24,7 +25,7 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
kinds = listOf(PrivateDmEvent.kind, GiftWrapEvent.kind),
authors = listOf(myPeer.pubkeyHex),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
@@ -20,7 +21,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
kinds = listOf(PrivateDmEvent.kind, GiftWrapEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
@@ -1,6 +1,10 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -22,7 +26,7 @@ object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
hashToLoad
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind),
limit = 200
)
)
@@ -1,7 +1,11 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -26,7 +30,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind),
limit = 200
)
)
@@ -9,7 +9,7 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import fr.acinq.secp256k1.Hex
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
private var searchString: String? = null
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
@@ -54,6 +54,28 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
TypedFilter(
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, BadgeDefinitionEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PollNoteEvent.kind, ChannelCreateEvent.kind
),
search = mySearchString,
limit = 100
)
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(
ChannelMetadataEvent.kind,
ClassifiedsEvent.kind,
CommunityDefinitionEvent.kind,
EmojiPackEvent.kind,
HighlightEvent.kind,
LiveActivitiesEvent.kind,
PollNoteEvent.kind,
NNSEvent.kind
),
search = mySearchString,
limit = 100
)
@@ -12,7 +12,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
private var addressesToWatch = setOf<Note>()
private fun createTagToAddressFilter(): List<TypedFilter>? {
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
if (addressesToWatch.isEmpty()) {
@@ -25,12 +25,15 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind, GenericRepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind
TextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
PollNoteEvent.kind,
CommunityPostApprovalEvent.kind,
LiveActivitiesChatMessageEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime,
@@ -55,7 +58,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex)
authors = listOf(aTag.pubKeyHex),
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
} else {
@@ -64,7 +68,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex)
authors = listOf(aTag.pubKeyHex),
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
}
@@ -85,17 +90,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
LongTextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind
PollNoteEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime,
@@ -154,7 +154,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
val addressReactions = createTagToAddressFilter()
val addressReactions = createReactionsToWatchInAddressFilter()
singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
}
@@ -0,0 +1,75 @@
package com.vitorpamplona.amethyst.service
import com.goterl.lazysodium.Sodium
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.utils.Key
fun Sodium.crypto_stream_xchacha20_xor_ic(
cipher: ByteArray,
message: ByteArray,
messageLen: Long,
nonce: ByteArray,
ic: Long,
key: ByteArray
): Int {
/**
* C++ Code:
*
* unsigned char k2[crypto_core_hchacha20_OUTPUTBYTES];
* crypto_core_hchacha20(k2, n, k, NULL);
* return crypto_stream_chacha20_xor_ic(
* c, m, mlen, n + crypto_core_hchacha20_INPUTBYTES, ic, k2);
*/
val k2 = ByteArray(32)
val nonceChaCha = nonce.drop(16).toByteArray()
assert(nonceChaCha.size == 8)
crypto_core_hchacha20(k2, nonce, key, null)
return crypto_stream_chacha20_xor_ic(
cipher,
message,
messageLen,
nonceChaCha,
ic,
k2
)
}
fun Sodium.crypto_stream_xchacha20_xor(
cipher: ByteArray,
message: ByteArray,
messageLen: Long,
nonce: ByteArray,
key: ByteArray
): Int {
return crypto_stream_xchacha20_xor_ic(cipher, message, messageLen, nonce, 0, key)
}
fun SodiumAndroid.cryptoStreamXChaCha20Xor(
cipher: ByteArray,
message: ByteArray,
messageLen: Long,
nonce: ByteArray,
key: ByteArray
): Boolean {
require(!(messageLen < 0 || messageLen > message.size)) { "messageLen out of bounds: $messageLen" }
return crypto_stream_xchacha20_xor(
cipher,
message,
messageLen,
nonce,
key
) == 0
}
fun SodiumAndroid.cryptoStreamXChaCha20Xor(
messageBytes: ByteArray,
nonce: ByteArray,
key: Key
): ByteArray? {
val mLen = messageBytes.size
val cipher = ByteArray(mLen)
val sucessful = cryptoStreamXChaCha20Xor(cipher, messageBytes, mLen.toLong(), nonce, key.asBytes)
return if (sucessful) cipher else null
}
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean {
println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
return Character.charCount(this.codePointAt(pos)) == 2
}
@@ -6,7 +6,7 @@ import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.bechToBytes
import com.vitorpamplona.amethyst.service.nip19.Tlv
import com.vitorpamplona.amethyst.service.toByteArray
import com.vitorpamplona.amethyst.service.nip19.toByteArray
import com.vitorpamplona.amethyst.service.toNAddress
import fr.acinq.secp256k1.Hex
@@ -0,0 +1,72 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
@Immutable
class ChatMessageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
/**
* Recepients intended to receive this conversation
*/
private fun recipientsPubKey() = tags.mapNotNull {
if (it.size > 1 && it[0] == "p") it[1] else null
}
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
companion object {
const val kind = 14
fun create(
msg: String,
to: List<String>? = null,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): ChatMessageEvent {
val content = msg
val tags = mutableListOf<List<String>>()
to?.forEach {
tags.add(listOf("p", it))
}
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it, "", "mention"))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
geohash?.let {
tags.add(listOf("g", it))
}
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, ClassifiedsEvent.kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return ChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -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
@@ -274,52 +285,8 @@ open class Event(
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
BookmarkListEvent.kind -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
ClassifiedsEvent.kind -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig)
CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackEvent.kind -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackSelectionEvent.kind -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
NNSEvent.kind -> NNSEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
else -> this
fun Event.getRefinedEvent(lenient: Boolean = false): Event {
return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig, lenient)
}
fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: List<List<String>>, content: String): ByteArray {
@@ -0,0 +1,106 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.toHexKey
class EventFactory {
companion object {
fun create(
id: String,
pubKey: String,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: String,
lenient: Boolean
): Event {
val internedTags = tags.map {
it.map {
it.intern()
}
}
return internedCreate(
id = id.intern(),
pubKey = pubKey.intern(),
createdAt = createdAt,
kind = kind,
tags = internedTags,
content = content,
sig = sig,
lenient = lenient
)
}
fun internedCreate(
id: String,
pubKey: String,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: String,
lenient: Boolean
) = when (kind) {
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
BookmarkListEvent.kind -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
ChatMessageEvent.kind -> {
if (id.isBlank()) {
val id = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey()
ChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
} else {
ChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
}
}
ClassifiedsEvent.kind -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig)
CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
// Will never happen.
// DirectMessageEvent.kind -> DirectMessageEvent(createdAt, tags, content)
EmojiPackEvent.kind -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackSelectionEvent.kind -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
SealedGossipEvent.kind -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
GiftWrapEvent.kind -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
NNSEvent.kind -> NNSEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
else -> Event(id, pubKey, createdAt, kind, tags, content, sig)
}
}
}
@@ -6,6 +6,8 @@ import java.math.BigDecimal
@Immutable
interface EventInterface {
fun countMemory(): Long
fun id(): HexKey
fun pubKey(): HexKey
@@ -28,9 +28,9 @@ abstract class GeneralListEvent(
if (content.isBlank()) return null
return try {
val sharedSecret = CryptoUtils.getSharedSecret(privKey, pubKey.hexToByteArray())
val sharedSecret = CryptoUtils.getSharedSecretNIP04(privKey, pubKey.hexToByteArray())
return CryptoUtils.decrypt(content, sharedSecret)
return CryptoUtils.decryptNIP04(content, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message} for ${dTag()}")
null
@@ -91,7 +91,7 @@ abstract class GeneralListEvent(
}
val msg = gson.toJson(privTags)
return CryptoUtils.encrypt(
return CryptoUtils.encryptNIP04(
msg,
privateKey,
pubKey
@@ -102,7 +102,7 @@ abstract class GeneralListEvent(
privateTags: List<List<String>>? = null,
privateKey: ByteArray
): String {
return CryptoUtils.encrypt(
return CryptoUtils.encryptNIP04(
msg = gson.toJson(privateTags),
privateKey = privateKey,
pubKey = CryptoUtils.pubkeyCreate(privateKey)
@@ -0,0 +1,84 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.EncryptedInfo
import com.vitorpamplona.amethyst.service.relays.Client
@Immutable
class GiftWrapEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
fun cachedGossip(privKey: ByteArray): Event? {
val hex = privKey.toHexKey()
if (cachedInnerEvent.contains(hex)) return cachedInnerEvent[hex]
val myInnerEvent = unwrap(privKey = privKey)
cachedInnerEvent = cachedInnerEvent + Pair(hex, myInnerEvent)
return myInnerEvent
}
fun unwrap(privKey: ByteArray) = try {
plainContent(privKey)?.let { fromJson(it, Client.lenient) }
} catch (e: Exception) {
// Log.e("UnwrapError", "Couldn't Decrypt the content", e)
null
}
private fun plainContent(privKey: ByteArray): String? {
if (content.isBlank()) return null
return try {
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privKey, pubKey.hexToByteArray())
val toDecrypt = gson.fromJson<EncryptedInfo>(
content,
EncryptedInfo::class.java
)
return CryptoUtils.decryptNIP24(toDecrypt, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message}")
null
}
}
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
companion object {
const val kind = 1059
fun create(
event: Event,
recipientPubKey: HexKey,
createdAt: Long = TimeUtils.now()
): GiftWrapEvent {
val privateKey = CryptoUtils.privkeyCreate() // GiftWrap is always a random key
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, recipientPubKey.hexToByteArray())
val content = gson.toJson(
CryptoUtils.encryptNIP24(
gson.toJson(event),
sharedSecret
)
)
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(listOf("p", recipientPubKey))
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return GiftWrapEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -35,9 +35,9 @@ class LnZapPaymentRequestEvent(
}
return try {
val sharedSecret = CryptoUtils.getSharedSecret(privKey, pubkey)
val sharedSecret = CryptoUtils.getSharedSecretNIP04(privKey, pubkey)
val jsonText = CryptoUtils.decrypt(content, sharedSecret)
val jsonText = CryptoUtils.decryptNIP04(content, sharedSecret)
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
@@ -62,7 +62,7 @@ class LnZapPaymentRequestEvent(
val pubKey = CryptoUtils.pubkeyCreate(privateKey)
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
val content = CryptoUtils.encrypt(
val content = CryptoUtils.encryptNIP04(
serializedRequest,
privateKey,
walletServicePubkey.hexToByteArray()
@@ -30,9 +30,9 @@ class LnZapPaymentResponseEvent(
private fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = CryptoUtils.getSharedSecret(privKey, pubKey)
val sharedSecret = CryptoUtils.getSharedSecretNIP04(privKey, pubKey)
val retVal = CryptoUtils.decrypt(content, sharedSecret)
val retVal = CryptoUtils.decryptNIP04(content, sharedSecret)
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
retVal.substring(16)
@@ -136,7 +136,7 @@ class LnZapRequestEvent(
}
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = CryptoUtils.getSharedSecret(privkey, pubkey)
var sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey)
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
@@ -155,7 +155,7 @@ class LnZapRequestEvent(
}
private fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = CryptoUtils.getSharedSecret(privkey, pubkey)
var sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey)
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
throw IllegalArgumentException("Invalid shared secret size")
}
@@ -23,9 +23,9 @@ class MuteListEvent(
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = CryptoUtils.getSharedSecret(privKey, pubKey.hexToByteArray())
val sharedSecret = CryptoUtils.getSharedSecretNIP04(privKey, pubKey.hexToByteArray())
return CryptoUtils.decrypt(content, sharedSecret)
return CryptoUtils.decryptNIP04(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
@@ -87,7 +87,7 @@ class MuteListEvent(
}
val msg = gson.toJson(privTags)
val content = CryptoUtils.encrypt(
val content = CryptoUtils.encryptNIP04(
msg,
privateKey,
pubKey
@@ -0,0 +1,32 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
class NIP24Factory {
fun createMsgNIP24(
msg: String,
to: List<HexKey>,
from: ByteArray
): List<GiftWrapEvent> {
val senderPublicKey = CryptoUtils.pubkeyCreate(from).toHexKey()
val senderMessage = ChatMessageEvent.create(
msg = msg,
to = to,
privateKey = from
)
return to.plus(senderPublicKey).map {
GiftWrapEvent.create(
event = SealedGossipEvent.create(
event = senderMessage,
encryptTo = it,
privateKey = from
),
recipientPubKey = it
)
}
}
}
@@ -55,9 +55,9 @@ class PrivateDmEvent(
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = CryptoUtils.getSharedSecret(privKey, pubKey)
val sharedSecret = CryptoUtils.getSharedSecretNIP04(privKey, pubKey)
val retVal = CryptoUtils.decrypt(content, sharedSecret)
val retVal = CryptoUtils.decryptNIP04(content, sharedSecret)
if (retVal.startsWith(nip18Advertisement)) {
retVal.substring(16)
@@ -89,7 +89,7 @@ class PrivateDmEvent(
zapRaiserAmount: Long?,
geohash: String? = null
): PrivateDmEvent {
val content = CryptoUtils.encrypt(
val content = CryptoUtils.encryptNIP04(
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
privateKey,
recipientPubKey
@@ -0,0 +1,122 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.EncryptedInfo
import com.vitorpamplona.amethyst.service.relays.Client
@Immutable
class SealedGossipEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
fun cachedGossip(privKey: ByteArray): Event? {
val hex = privKey.toHexKey()
if (cachedInnerEvent.contains(hex)) return cachedInnerEvent[hex]
val gossip = unseal(privKey = privKey)
val event = gossip?.mergeWith(this)
cachedInnerEvent = cachedInnerEvent + Pair(hex, event)
return event
}
fun unseal(privKey: ByteArray): Gossip? = try {
plainContent(privKey)?.let { gson.fromJson(it, Gossip::class.java) }
} catch (e: Exception) {
null
}
private fun plainContent(privKey: ByteArray): String? {
if (content.isBlank()) return null
return try {
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privKey, pubKey.hexToByteArray())
val toDecrypt = gson.fromJson<EncryptedInfo>(
content,
EncryptedInfo::class.java
)
return CryptoUtils.decryptNIP24(toDecrypt, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message}")
null
}
}
companion object {
const val kind = 13
fun create(
event: Event,
encryptTo: HexKey,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): SealedGossipEvent {
val gossip = Gossip.create(event)
return create(gossip, encryptTo, privateKey, createdAt)
}
fun create(
gossip: Gossip,
encryptTo: HexKey,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): SealedGossipEvent {
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, encryptTo.hexToByteArray())
val content = gson.toJson(
CryptoUtils.encryptNIP24(
gson.toJson(gossip),
sharedSecret
)
)
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return SealedGossipEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
class Gossip(
val id: HexKey?,
@SerializedName("pubkey")
val pubKey: HexKey?,
@SerializedName("created_at")
val createdAt: Long?,
val kind: Int?,
val tags: List<List<String>>?,
val content: String?
) {
fun mergeWith(event: SealedGossipEvent): Event {
val newPubKey = pubKey?.ifBlank { null } ?: event.pubKey
val newCreatedAt = if (createdAt != null && createdAt > 1000) createdAt else event.createdAt
val newKind = kind ?: 0
val newTags = (tags ?: emptyList()).plus(event.tags)
val newContent = content ?: ""
val newID = id?.ifBlank { null } ?: Event.generateId(newPubKey, newCreatedAt, newKind, newTags, newContent).toHexKey()
val sig = ""
return EventFactory.create(newID, newPubKey, newCreatedAt, newKind, newTags, newContent, sig, Client.lenient)
}
companion object {
fun create(event: Event): Gossip {
return Gossip(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content)
}
}
}
@@ -5,7 +5,6 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.bechToBytes
import com.vitorpamplona.amethyst.service.toByteArray
import com.vitorpamplona.amethyst.service.toNEvent
import java.util.regex.Pattern
@@ -170,3 +169,11 @@ object Nip19 {
return fullArray.toNEvent()
}
}
fun Int.toByteArray(): ByteArray {
val bytes = ByteArray(4)
(0..3).forEach {
bytes[3 - it] = ((this ushr (8 * it)) and 0xFFFF).toByte()
}
return bytes
}
@@ -18,6 +18,8 @@ import com.vitorpamplona.amethyst.ui.MainActivity
object NotificationUtils {
private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
@@ -70,7 +72,7 @@ object NotificationUtils {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, ZAP_GROUP_KEY, applicationContext)
}
fun NotificationManager.sendDMNotification(
@@ -84,7 +86,7 @@ object NotificationUtils {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, DM_GROUP_KEY, applicationContext)
}
fun NotificationManager.sendNotification(
@@ -94,6 +96,7 @@ object NotificationUtils {
pictureUrl: String?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context
) {
if (pictureUrl != null) {
@@ -110,6 +113,7 @@ object NotificationUtils {
picture = imageResult.drawable as? BitmapDrawable,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext
)
} else {
@@ -120,6 +124,7 @@ object NotificationUtils {
picture = null,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext
)
}
@@ -132,6 +137,7 @@ object NotificationUtils {
picture: BitmapDrawable?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context
) {
val notId = id.hashCode()
@@ -164,7 +170,8 @@ object NotificationUtils {
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
.setGroup(messageTitle)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
@@ -178,7 +185,8 @@ object NotificationUtils {
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
.setGroup(messageTitle)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -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)
}
@@ -17,16 +17,11 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
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.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
@@ -44,7 +39,6 @@ import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.navigation.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -58,15 +52,10 @@ import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class MainActivity : AppCompatActivity() {
lateinit var navController: NavHostController
@RequiresApi(Build.VERSION_CODES.R)
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()) {
@@ -75,7 +64,6 @@ class MainActivity : AppCompatActivity() {
}
setContent {
navController = rememberNavController()
val themeViewModel: ThemeViewModel = viewModel()
themeViewModel.onChange(LocalPreferences.getTheme())
@@ -86,20 +74,9 @@ class MainActivity : AppCompatActivity() {
AccountStateViewModel(this@MainActivity)
}
AccountScreen(accountStateViewModel, themeViewModel, navController)
AccountScreen(accountStateViewModel, themeViewModel)
}
}
var actionableNextPage by remember { mutableStateOf(startingPage) }
actionableNextPage?.let {
LaunchedEffect(it) {
navController.navigate(it) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
}
actionableNextPage = null
}
}
val networkRequest = NetworkRequest.Builder()
@@ -158,41 +135,6 @@ class MainActivity : AppCompatActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (this::navController.isInitialized) {
val uri = intent?.data?.toString()
val startingPage = uriToRoute(uri)
startingPage?.let { route ->
val currentRoute = getRouteWithArguments(navController)
if (!isSameRoute(currentRoute, route)) {
navController.navigate(route) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
}
}
}
}
private fun isSameRoute(currentRoute: String?, newRoute: String): Boolean {
if (currentRoute == null) return false
if (currentRoute == newRoute) {
return true
}
if (newRoute.startsWith("Event/") && currentRoute.contains("/")) {
if (newRoute.split("/")[1] == currentRoute.split("/")[1]) {
return true
}
}
return false
}
@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,
@@ -323,7 +323,7 @@ fun loadRelayInfo(
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
@@ -334,7 +334,7 @@ fun loadRelayInfo(
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
@@ -348,7 +348,7 @@ fun loadRelayInfo(
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
@@ -361,7 +361,7 @@ fun loadRelayInfo(
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
@@ -120,7 +120,7 @@ fun RelaySelectionDialog(
val selectedRelays = relays.filter { it.isSelected }
if (selectedRelays.isEmpty()) {
scope.launch {
Toast.makeText(context, "Select a relay to continue", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.select_a_relay_to_continue), Toast.LENGTH_SHORT).show()
}
return@PostButton
}
@@ -149,7 +149,7 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
scope.launch {
Toast.makeText(
context,
"No Lightning Address set",
context.getString(R.string.no_lightning_address_set),
Toast.LENGTH_SHORT
).show()
}
@@ -180,7 +180,7 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
var orignaltoken = token.token
clipboardManager.setText(AnnotatedString("$orignaltoken"))
scope.launch {
Toast.makeText(context, "Copied token to clipboard", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.copied_token_to_clipboard), Toast.LENGTH_SHORT).show()
}
}
},
@@ -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
}
}
}
@@ -21,6 +21,7 @@ class CommunityFeedFilter(val note: AddressableNote, val account: Account) : Add
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myUnapprovedPosts = collection.asSequence()
.filter { it.event is CommunityPostApprovalEvent } // Only Approvals
.filter { it.author?.pubkeyHex == account.userProfile().pubkeyHex } // made by the logged in user
.filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // for this community
.filter { it.isNewThread() } // check if it is a new thread
@@ -25,17 +25,17 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
val notes = innerApplyFilter(LocalCache.notes.values, true)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false)
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
return innerApplyFilter(collection, false)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
private fun innerApplyFilter(collection: Collection<Note>, ignoreAddressables: Boolean): Set<Note> {
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
@@ -52,6 +52,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
.filter { it ->
val noteEvent = it.event
(noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
(!ignoreAddressables || noteEvent.kind() < 10000) &&
(isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeoSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
(isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) &&
@@ -1,12 +1,22 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.util.Consumer
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
@@ -36,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -237,4 +248,60 @@ fun AppNavigation(
})
}
}
val activity = LocalContext.current.getActivity()
var actionableNextPage by remember {
mutableStateOf(uriToRoute(activity.intent?.data?.toString()?.ifBlank { null }))
}
actionableNextPage?.let {
LaunchedEffect(it) {
navController.navigate(it) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
}
actionableNextPage = null
}
DisposableEffect(navController) {
val consumer = Consumer<Intent> { intent ->
val uri = intent?.data?.toString()
val newPage = uriToRoute(uri)
newPage?.let { route ->
val currentRoute = getRouteWithArguments(navController)
if (!isSameRoute(currentRoute, route)) {
navController.navigate(route) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
}
}
}
activity.addOnNewIntentListener(consumer)
onDispose {
activity.removeOnNewIntentListener(consumer)
}
}
}
fun Context.getActivity(): MainActivity {
if (this is MainActivity) return this
return if (this is ContextWrapper) baseContext.getActivity() else getActivity()
}
private fun isSameRoute(currentRoute: String?, newRoute: String): Boolean {
if (currentRoute == null) return false
if (currentRoute == newRoute) {
return true
}
if (newRoute.startsWith("Event/") && currentRoute.contains("/")) {
if (newRoute.split("/")[1] == currentRoute.split("/")[1]) {
return true
}
}
return false
}
@@ -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
)
}
}
@@ -511,7 +562,15 @@ fun debugState(context: Context) {
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "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", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.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 ")
}
LocalCache.addressables.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
@@ -7,7 +7,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
@@ -15,8 +14,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
@Composable
fun AccountScreen(
accountStateViewModel: AccountStateViewModel,
themeViewModel: ThemeViewModel,
navController: NavHostController
themeViewModel: ThemeViewModel
) {
val accountState by accountStateViewModel.accountContent.collectAsState()
@@ -32,7 +30,7 @@ fun AccountScreen(
factory = AccountViewModel.Factory(state.account)
)
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController)
MainScreen(accountViewModel, accountStateViewModel, themeViewModel)
}
is AccountState.LoggedInViewOnly -> {
val accountViewModel: AccountViewModel = viewModel(
@@ -40,7 +38,7 @@ fun AccountScreen(
factory = AccountViewModel.Factory(state.account)
)
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController)
MainScreen(accountViewModel, accountStateViewModel, themeViewModel)
}
}
}
@@ -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
)
}
@@ -24,11 +24,12 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
import com.vitorpamplona.amethyst.ui.buttons.NewImageButton
@@ -60,8 +61,7 @@ import kotlinx.coroutines.launch
fun MainScreen(
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
themeViewModel: ThemeViewModel,
navController: NavHostController
themeViewModel: ThemeViewModel
) {
val scope = rememberCoroutineScope()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
@@ -71,6 +71,7 @@ fun MainScreen(
skipHalfExpanded = true
)
val navController = rememberNavController()
val navState = navController.currentBackStackEntryAsState()
val nav = remember(navController) {
@@ -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)
}
}
@@ -1015,7 +1015,7 @@ fun DisplayLNAddress(
scope.launch {
Toast.makeText(
context,
"Payment Successful", // Turn this into a UI animation
context.getString(R.string.payment_successful), // Turn this into a UI animation
Toast.LENGTH_LONG
).show()
}
@@ -1025,7 +1025,7 @@ fun DisplayLNAddress(
context,
response.error?.message
?: response.error?.code?.toString()
?: "Error parsing error message",
?: context.getString(R.string.error_parsing_error_message),
Toast.LENGTH_LONG
).show()
}
@@ -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
+18 -1
View File
@@ -47,6 +47,8 @@
<string name="and">" a "</string>
<string name="in_channel">"v kanálu "</string>
<string name="profile_banner">Banner profilu</string>
<string name="payment_successful">Platba úspěšná</string>
<string name="error_parsing_error_message">Chyba při zpracování chybové zprávy</string>
<string name="following">" Sleduje"</string>
<string name="followers">" Sledující"</string>
<string name="profile">Profil</string>
@@ -412,7 +414,7 @@
<string name="sats_to_complete">Zapraiser na %1$s. Do cíle zbývá %2$s sats</string>
<string name="read_from_relay">Číst z Relay</string>
<string name="write_to_relay">Zapisovat do Relay</string>
<string name="an_error_ocurred_trying_to_get_relay_information">Při pokusu o získání informací z Relay se vyskytla chyba z %1$s</string>
<string name="an_error_occurred_trying_to_get_relay_information">Při pokusu o získání informací z Relay se vyskytla chyba z %1$s</string>
<string name="owner">Vlastník</string>
<string name="version">Verze</string>
<string name="software">Software</string>
@@ -437,6 +439,8 @@
<string name="payment">Platba</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Vyměnit</string>
<string name="no_lightning_address_set">Není nastavena žádná Lightning adresa</string>
<string name="copied_token_to_clipboard">Token zkopírován do schránky</string>
<string name="live_stream_live_tag">ŽIVĚ</string>
<string name="live_stream_offline_tag">OFFLINE</string>
@@ -463,6 +467,8 @@
<string name="settings">Nastavení</string>
<string name="connectivity_type_always">Vždy</string>
<string name="connectivity_type_wifi_only">Pouze Wi-Fi</string>
<string name="connectivity_type_never">Nikdy</string>
<string name="system">Systém</string>
<string name="light">Světlý</string>
<string name="dark">Tmavý</string>
@@ -483,4 +489,15 @@
<string name="nip05_verified">Adresa Nostr byla ověřena</string>
<string name="nip05_failed">Ověření adresy Nostr se nezdařilo</string>
<string name="nip05_checking">Kontrola adresy Nostr</string>
<string name="select_deselect_all">Vybrat/Zrušit vše</string>
<string name="default_relays">Výchozí</string>
<string name="select_a_relay_to_continue">Vyberte relé pro pokračování</string>
<string name="zap_forward_title">Přeposílat Zapy na:</string>
<string name="zap_forward_explainer">Podporující klienti budou přeposílat Zapy na níže uvedenou LN adresu nebo uživatelský profil místo vaší</string>
<string name="geohash_title">Zveřejnit polohu jako </string>
<string name="geohash_explainer">Přidá Geohash vaší polohy do příspěvku. Veřejnost bude vědět, že se nacházíte do 5 km od aktuální polohy</string>
<string name="add_sensitive_content_explainer">Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující</string>
</resources>
+18 -1
View File
@@ -47,6 +47,8 @@
<string name="and">" und "</string>
<string name="in_channel">"im Kanal "</string>
<string name="profile_banner">Profilbanner</string>
<string name="payment_successful">Zahlung erfolgreich</string>
<string name="error_parsing_error_message">Fehler beim Parsen der Fehlermeldung</string>
<string name="following">" Folgen"</string>
<string name="followers">" Anhänger"</string>
<string name="profile">Profil</string>
@@ -421,7 +423,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="sats_to_complete">Zapraiser bei %1$s. %2$s Sats bis zum Ziel</string>
<string name="read_from_relay">Von Relay lesen</string>
<string name="write_to_relay">In Relay schreiben</string>
<string name="an_error_ocurred_trying_to_get_relay_information">Ein Fehler ist beim Abrufen von Relay-Informationen von %1$s aufgetreten</string>
<string name="an_error_occurred_trying_to_get_relay_information">Ein Fehler ist beim Abrufen von Relay-Informationen von %1$s aufgetreten</string>
<string name="owner">Inhaber</string>
<string name="version">Version</string>
<string name="software">Software</string>
@@ -446,6 +448,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="payment">Zahlung</string>
<string name="cashu">Cashu-Token</string>
<string name="cashu_redeem">Einlösen</string>
<string name="no_lightning_address_set">Keine Lightning-Adresse festgelegt</string>
<string name="copied_token_to_clipboard">Token in die Zwischenablage kopiert</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
@@ -472,6 +476,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="settings">Einstellungen</string>
<string name="connectivity_type_always">Immer</string>
<string name="connectivity_type_wifi_only">Nur WLAN</string>
<string name="connectivity_type_never">Nie</string>
<string name="system">System</string>
<string name="light">Hell</string>
<string name="dark">Dunkel</string>
@@ -492,4 +498,15 @@ anz der Bedingungen ist erforderlich</string>
<string name="nip05_verified">Nostr-Adresse verifiziert</string>
<string name="nip05_failed">Nostr-Adresse konnte nicht verifiziert werden</string>
<string name="nip05_checking">Nostr-Adresse wird überprüft</string>
<string name="select_deselect_all">Alle auswählen/abwählen</string>
<string name="default_relays">Standard</string>
<string name="select_a_relay_to_continue">Wählen Sie ein Relais aus, um fortzufahren</string>
<string name="zap_forward_title">Weiterleiten von Zaps an:</string>
<string name="zap_forward_explainer">Unterstützende Clients leiten Zaps an die LNAddress oder das Benutzerprofil unten weiter, anstatt an Ihre eigene Adresse</string>
<string name="geohash_title">Ort preisgeben als </string>
<string name="geohash_explainer">Fügt dem Beitrag einen Geohash Ihres Standorts hinzu. Die Öffentlichkeit wird wissen, dass Sie sich innerhalb von 5 km (3 mi) vom aktuellen Standort befinden</string>
<string name="add_sensitive_content_explainer">Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten</string>
</resources>
+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>
-1
View File
@@ -406,7 +406,6 @@
<string name="sats_to_complete">A ZapGyűjtés %1$s-nál. %2$s sats kell a célig</string>
<string name="read_from_relay">Olvassás a csomópontból</string>
<string name="write_to_relay">Írás a csomópontra</string>
<string name="an_error_ocurred_trying_to_get_relay_information">Hiba történt a csomópont információ szerzés közben a %1$s -tól</string>
<string name="owner">Tulajdonos</string>
<string name="version">Verzió</string>
<string name="software">Szoftver</string>
-1
View File
@@ -411,7 +411,6 @@
<string name="sats_to_complete">Zapraiser at %1$s. %2$s sats to goal</string>
<string name="read_from_relay">リレーから読み込み</string>
<string name="write_to_relay">リレーに書き込み</string>
<string name="an_error_ocurred_trying_to_get_relay_information">%1$sからリレー情報を取得する際にエラーが発生しました</string>
<string name="owner">管理者</string>
<string name="version">バージョン</string>
<string name="software">ソフトウェア</string>
-1
View File
@@ -414,7 +414,6 @@
<string name="sats_to_complete">Zapraiser op %1$s. %2$s sats tot doel</string>
<string name="read_from_relay">Lees van relay</string>
<string name="write_to_relay">Schrijf naar relay</string>
<string name="an_error_ocurred_trying_to_get_relay_information">Er is een fout opgetreden bij het ophalen van de relay-informatie van %1$s</string>
<string name="owner">Eigenaar</string>
<string name="version">Versie</string>
<string name="software">Software</string>
@@ -372,7 +372,6 @@
<string name="sats_to_complete">Arrecadação de Zaps em %1$s. %2$s sats para meta</string>
<string name="read_from_relay">Ler do Relay</string>
<string name="write_to_relay">Enviar para o Relay</string>
<string name="an_error_ocurred_trying_to_get_relay_information">Ocorreu um erro ao tentar obter informações do relay de %1$s</string>
<string name="owner">Proprietário</string>
<string name="version">Versão</string>
<string name="software">Programa</string>
+21 -4
View File
@@ -46,6 +46,8 @@
<string name="and">" och "</string>
<string name="in_channel">"i kanalen "</string>
<string name="profile_banner">Profil Banner</string>
<string name="payment_successful">Betalning lyckades</string>
<string name="error_parsing_error_message">Fel vid tolkning av felmeddelande</string>
<string name="following">" Följer"</string>
<string name="followers">" Följare"</string>
<string name="profile">Profil</string>
@@ -107,7 +109,7 @@
<string name="new_requests">Nya förfrågningar</string>
<string name="blocked_users">Blockerade användare</string>
<string name="new_threads">Nya trådar</string>
<string name="conversations">konversationer</string>
<string name="conversations">Konversationer</string>
<string name="notes">Anteckningar</string>
<string name="replies">Svar</string>
<string name="follows">"Följer"</string>
@@ -313,7 +315,7 @@
<string name="file_server">Fil Server</string>
<string name="zap_forward_lnAddress">LnAdress or @Användare</string>
<string name="zap_forward_lnAddress">LnAdress eller @Användare</string>
<string name="upload_server_imgur">imgur.com - betrodd</string>
<string name="upload_server_imgur_explainer">Imgur kan ändra filen</string>
@@ -386,7 +388,7 @@
<string name="channel_list_join_conversation">Gå med i konversation</string>
<string name="channel_list_user_or_group_id">Användare eller grupp ID</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent eller hex</string>
<string name="channel_list_create_channel">Skapa</string>
<string name="channel_list_join_channel">Gå med</string>
<string name="today">Idag</string>
@@ -409,7 +411,7 @@
<string name="sats_to_complete">Zapraiser på %1$s. %2$s sats kvar till målet</string>
<string name="read_from_relay">Läs från Relay</string>
<string name="write_to_relay">Skriv till Relay</string>
<string name="an_error_ocurred_trying_to_get_relay_information">Ett fel inträffade vid försök att hämta information från Relay %1$s</string>
<string name="an_error_occurred_trying_to_get_relay_information">Ett fel inträffade vid försök att hämta information från Relay %1$s</string>
<string name="owner">Ägare</string>
<string name="version">Version</string>
<string name="software">Programvara</string>
@@ -434,6 +436,8 @@
<string name="payment">Betalning</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Inlösen</string>
<string name="no_lightning_address_set">Ingen Lightning-adress angiven</string>
<string name="copied_token_to_clipboard">Kopierade token till urklipp</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
@@ -460,6 +464,8 @@
<string name="settings">Inställningar</string>
<string name="connectivity_type_always">Alltid</string>
<string name="connectivity_type_wifi_only">Endast Wi-Fi</string>
<string name="connectivity_type_never">Aldrig</string>
<string name="system">System</string>
<string name="light">Ljus</string>
<string name="dark">Mörk</string>
@@ -480,6 +486,17 @@
<string name="nip05_verified">Nostr-adress verifierad</string>
<string name="nip05_failed">Verifikation av Nostr-adress misslyckades</string>
<string name="nip05_checking">Kontrollerar Nostr-adress</string>
<string name="select_deselect_all">Välj/Avmarkera alla</string>
<string name="default_relays">Standard</string>
<string name="select_a_relay_to_continue">Välj ett relä för att fortsätta</string>
<string name="zap_forward_title">Vidarebefordra Zaps till:</string>
<string name="zap_forward_explainer">Stödjande klienter kommer att vidarebefordra Zaps till LNAddress eller användarprofilen nedan istället för din egen</string>
<string name="geohash_title">Exponera plats som </string>
<string name="geohash_explainer">Lägger till en Geohash av din plats i inlägget. Allmänheten kommer att veta att du befinner dig inom 5 km från nuvarande plats</string>
<string name="add_sensitive_content_explainer">Lägger till en varning för känsligt innehåll innan ditt innehåll visas. Detta är idealiskt för NSFW-innehåll (inte säkert för arbete) eller innehåll som vissa personer kan uppleva som stötande eller störande</string>
</resources>
+107 -108
View File
@@ -2,7 +2,7 @@
<resources>
<string name="point_to_the_qr_code">QR குறியீட்டைச் சுட்டிக்காட்டவும்</string>
<string name="show_qr">QR ஐக் காட்டவும்</string>
<string name="profile_image">சுயவிவரப் படம்</string>
<string name="profile_image">தன்விவரப் படம்</string>
<string name="scan_qr">QR ஐ ஸ்கேன் செய்யவும்</string>
<string name="show_anyway">பரவாயில்லை, காட்டவும்</string>
<string name="post_was_flagged_as_inappropriate_by">குறிப்பு தகாதது என குறிக்கப்பட்டது. குறித்தவர்</string>
@@ -12,22 +12,22 @@
<string name="could_not_decrypt_the_message">செய்தியை மறைகுறியாக்க முடியவில்லை</string>
<string name="group_picture">குழுப் படம்</string>
<string name="explicit_content">வெளிப்படையான உள்ளடக்கம்</string>
<string name="spam">ஸ்பேம்</string>
<string name="spam">எரிதம் (தேவையற்றவை)</string>
<string name="impersonation">ஆள்மாறாட்டம்</string>
<string name="illegal_behavior">சட்டவிரோத நடத்தை</string>
<string name="unknown">தெரியாத</string>
<string name="unknown">தெரியாதவை</string>
<string name="relay_icon">ரிலே ஐகான்</string>
<string name="unknown_author">எழுதியவர் தெரியவில்லை</string>
<string name="copy_text">உரையை நகலெடுக்கவும்</string>
<string name="copy_user_pubkey">எழுதியவர் IDஐ நகலெடுக்கவும்</string>
<string name="copy_note_id">குறிப்பின் IDஐ நகலெடுக்கவும்</string>
<string name="broadcast">ஒளிபரப்பு</string>
<string name="request_deletion">நீக்குவதற்கு கோரிக்கை செய்</string>
<string name="block_hide_user">யூஸரை முடக்கு + மறை</string>
<string name="report_spam_scam">ஸ்பேம் / மோசடி என்று புகாரளிக்கவும்</string>
<string name="request_deletion">நீக்கக் கோரு</string>
<string name="block_hide_user">பயனரை முடக்கு + மறை</string>
<string name="report_spam_scam">எரிதம் / மோசடி என்று புகாரளிக்கவும்</string>
<string name="report_impersonation">ஆள்மாறாட்டத்தைப் புகாரளிக்கவும்</string>
<string name="report_explicit_content">வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும்</string>
<string name="report_illegal_behaviour">சட்டவிரோத நடத்தையைப் புகாரளிக்கவும்</string>
<string name="report_illegal_behaviour">சட்டப்புறம்பான நடத்தையைப் புகாரளிக்கவும்</string>
<string name="login_with_a_private_key_to_be_able_to_reply">பதிலளிக்க ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">குறிப்புகளை உயர்த்த ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="login_with_a_private_key_to_like_posts">குறிப்புகளை விரும்புவதற்கு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
@@ -43,10 +43,10 @@
<string name="replying_to">"பதில் பெறுநர் "</string>
<string name="and">மற்றும்</string>
<string name="in_channel">"அலை வரிசையில் "</string>
<string name="profile_banner">சுயவிவர பேனர்</string>
<string name="following">" பின்பற்றப் படுவோர்"</string>
<string name="profile_banner">தன்விவர பேனர்</string>
<string name="following">" பின்பற்றப்படுவோர்"</string>
<string name="followers">" பின்பற்றுவோர்"</string>
<string name="profile">சுயவிவரம்</string>
<string name="profile">தன்விவரம்</string>
<string name="security_filters">பாதுகாப்பு வடிப்பான்கள்</string>
<string name="log_out">வெளியேறு</string>
<string name="show_more">மேலும் காட்டு</string>
@@ -62,40 +62,40 @@
<string name="new_channel">புதிய அலைவரிசை</string>
<string name="channel_name">அலைவரிசைப் பெயர்</string>
<string name="my_awesome_group">எனது அருமையான குழு</string>
<string name="picture_url">பட Url</string>
<string name="picture_url">படச்சுட்டி</string>
<string name="description">தகவல்</string>
<string name="about_us">"எங்களைப் பற்றி.. "</string>
<string name="what_s_on_your_mind">உங்கள் மனதில் என்ன உள்ளது?</string>
<string name="post">குறிப்பு</string>
<string name="post">திவ</string>
<string name="save">சேமி</string>
<string name="create">உருவாக்கு</string>
<string name="cancel">ரத்து</string>
<string name="failed_to_upload_the_image">படத்தை பதிவேற்றுதல் தோல்வி</string>
<string name="failed_to_upload_the_image">படத்தை பதிவேற்ற இயலவில்லை</string>
<string name="relay_address">ரிலே முகவரி</string>
<string name="posts">குறிப்புகள்</string>
<string name="bytes">பைட்டுகள்</string>
<string name="bytes">துணுக்குகள்</string>
<string name="errors">பிழைகள்</string>
<string name="home_feed">முகப்பு ஊட்டம்</string>
<string name="private_message_feed">தனிப்பட்ட செய்தி ஊட்டம்</string>
<string name="private_message_feed">தனிசெய்தி ஊட்டம்</string>
<string name="public_chat_feed">பொது செய்தி ஊட்டம்</string>
<string name="global_feed">முழுதளாவிய ஊட்டம்</string>
<string name="search_feed">தேடுதல் ஊட்டம்</string>
<string name="add_a_relay">ஒரு ரிலேவை சேர்</string>
<string name="display_name">காட்சிப் பெயர்</string>
<string name="my_display_name">எனது காட்சிப் பெயர்</string>
<string name="username">யூஸர் பெயர்</string>
<string name="my_username">எனது யூஸர் பெயர்</string>
<string name="username">பயனர் பெயர்</string>
<string name="my_username">எனது பயனர் பெயர்</string>
<string name="about_me">என்னைப் பற்றி</string>
<string name="avatar_url">சுயவிவரப் படURL</string>
<string name="banner_url">பேனர் URL</string>
<string name="website_url">வலைத்தள URL</string>
<string name="avatar_url">தன்விவரப் படசுட்டி</string>
<string name="banner_url">பேனர் சுட்டி</string>
<string name="website_url">வலைதள முகவரி</string>
<string name="ln_address">LN முகவரி </string>
<string name="ln_url_outdated">LN URL (காலாவதியானது)</string>
<string name="image_saved_to_the_gallery">படம் கேலரியில் சேமிக்கப் பட்டது</string>
<string name="failed_to_save_the_image">படத்தை சேமிப்பது தோல்வியடைந்தது</string>
<string name="upload_image">படத்தை பதிவேற்று</string>
<string name="uploading">பதிவேற்றம்...</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">யூஸர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
<string name="image_saved_to_the_gallery">படம் காட்சியகத்தில் சேமிக்கப்பட்டது</string>
<string name="failed_to_save_the_image">படத்தைச் சேமிக்க இயலவில்லை</string>
<string name="upload_image">படத்தைப் பதிவேற்று</string>
<string name="uploading">பதிவேற்றுகிறது....</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
<string name="reply_here">"இங்கு பதிலளிக்கவும்.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும்</string>
<string name="copy_channel_id_note_to_the_clipboard">அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு</string>
@@ -103,12 +103,12 @@
<string name="join">சேர்</string>
<string name="known">தெரிந்த</string>
<string name="new_requests">புதிய கோரிக்கைகள்</string>
<string name="blocked_users">முடக்கப்பட்ட யூஸர்கள்</string>
<string name="new_threads">புதிய நூல்கள்</string>
<string name="blocked_users">முடக்கப்பட்டப் பயனர்கள்</string>
<string name="new_threads">புதிய இழைகள்</string>
<string name="conversations">உரையாடல்கள்</string>
<string name="notes">குறிப்புகள்</string>
<string name="replies">பதில்கள்</string>
<string name="follows">"பின்தொடர படுவோர்"</string>
<string name="follows">"பின்தொடரப்படுவோர்"</string>
<string name="reports">"புகார்கள்"</string>
<string name="more_options">மேலும் </string>
<string name="relays">" ரிலேகள்"</string>
@@ -118,29 +118,29 @@
<string name="copy_private_key_to_the_clipboard">கிளிப்போர்டுக்கு ரகசிய சாவியை நகலெடுக்கவும்</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது</string>
<string name="copy_public_key_npub_to_the_clipboard">கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும்</string>
<string name="send_a_direct_message">நேரடி செய்தியை அனுப்பவும்</string>
<string name="edits_the_user_s_metadata">யூஸரின்தகவலை திருத்துகிறது</string>
<string name="send_a_direct_message">நேரடிச் செய்தியை அனுப்ப</string>
<string name="edits_the_user_s_metadata">பயனரின் தகவலை திருத்துகிறது</string>
<string name="follow">பின்தொடர்</string>
<string name="follow_back">பதிலுக்கு பின்தொடரவும்</string>
<string name="unblock">தடையை நீக்கு</string>
<string name="copy_user_id">யூஸர் IDயை நகலெடுக்கவும்</string>
<string name="unblock_user">யூஸர் தடையை நீக்கு</string>
<string name="npub_hex_username">"npub, hex, யூஸர்பெயர் "</string>
<string name="copy_user_id">பயனர் IDயை நகலெடுக்கவும்</string>
<string name="unblock_user">பயனரைத் தடைநீக்கு</string>
<string name="npub_hex_username">"npub, hex, பயனர்பெயர் "</string>
<string name="clear">அழி</string>
<string name="app_logo">பயன்பாட்டு லோகோ</string>
<string name="app_logo">பயன்பாட்டு இலச்சினை</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex தனிப்பட்ட சாவி</string>
<string name="show_password">கடவுச்சொல்லை காட்டவும்</string>
<string name="hide_password">கடவுச்சொல்லை மறைக்கவும்</string>
<string name="invalid_key">தவறான சாவி</string>
<string name="i_accept_the">"பின்வருவதை நான் ஏற்றுக்கொள்கிறேன்: "</string>
<string name="i_accept_the">"பின்வருவதை நான் ஏற்கிறேன்: "</string>
<string name="terms_of_use">பயன்பாட்டு விதிமுறைகளை</string>
<string name="acceptance_of_terms_is_required">விதிமுறைகளை ஏற்றுக்கொள்வது தேவை</string>
<string name="acceptance_of_terms_is_required">விதிமுறைகளை ஏற்பது கட்டாயம்</string>
<string name="key_is_required">சாவி தேவை</string>
<string name="login">உள்நுழை</string>
<string name="generate_a_new_key">புதிய சாவியை உருவாக்குங்கள்</string>
<string name="login">உள்நுழை</string>
<string name="generate_a_new_key">புதிய சாவியை உருவாக்கு</string>
<string name="loading_feed">ஊட்டம் ஏற்றப்படுகிறது</string>
<string name="error_loading_replies">"பதில்களை ஏற்றுவதில் பிழை: "</string>
<string name="try_again">ீண்டும் முயற்சி செய்</string>
<string name="try_again">றுமுயற்சி செய்</string>
<string name="feed_is_empty">ஊட்டம் காலியாக உள்ளது.</string>
<string name="refresh">புதுப்பி</string>
<string name="created">உருவாக்கப்பட்டது</string>
@@ -151,13 +151,13 @@
<string name="and_picture_to">மற்றும் படம்</string>
<string name="leave">வெளியேறு</string>
<string name="unfollow">பின்தொடர்வதை நிறுத்து</string>
<string name="channel_created">அலைவரிசை உருவாக்கப் பட்டது</string>
<string name="channel_created">அலைவரிசை உருவாக்கப்பட்டது</string>
<string name="channel_information_changed_to">"அலைவரிசை தகவல் மாற்றப்பட்டது"</string>
<string name="public_chat">பொது அரட்டை</string>
<string name="posts_received">குறிப்புகள் பெறப்பட்டன</string>
<string name="remove">அகற்று</string>
<string name="translations_auto">தானியங்கி</string>
<string name="translations_translated_from">மொழிபெயர்க்கப்பட்டுள்ளது மூலம்:</string>
<string name="translations_translated_from">ூல மொழி:</string>
<string name="translations_to"></string>
<string name="translations_show_in_lang_first">முதலில் %1$s இல் காண்பி</string>
<string name="translations_always_translate_to_lang">எப்போதும் %1$s க்கு மொழிபெயர்க்கவும்</string>
@@ -167,15 +167,15 @@
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">நிர்வாணம்</string>
<string name="profanity_hateful_speech">அவதூறு / வெறுக்கத்தக்க பேச்சு</string>
<string name="report_hateful_speech">வெறுக்கத்தக்க பேச்சைப் புகாரளிக்கவும்</string>
<string name="report_nudity_porn">நிர்வாணம் / ஆபாசத்தைப் புகாரளிக்கவும்</string>
<string name="others">மற்றவைகள்</string>
<string name="mark_all_known_as_read">அனைத்து தெரிந்தவைகளையும் படித்ததாக குறிக்கவும்</string>
<string name="mark_all_new_as_read">அனைத்து புதியதையும் படித்ததாக குறிக்கவும்</string>
<string name="mark_all_as_read">அனைத்தையும் படித்ததாக குறிக்கவும்</string>
<string name="backup_keys">காப்பு சாவிகள்</string>
<string name="nudity">பிறந்தமேனி</string>
<string name="profanity_hateful_speech">அவதூறு / வெறுப்புப் பேச்சு</string>
<string name="report_hateful_speech">வெறுப்புப் பேச்சைப் புகாரளிக்கவும்</string>
<string name="report_nudity_porn">பிறந்தமேனி / ஆபாசத்தைப் புகாரளிக்கவும்</string>
<string name="others">பிற</string>
<string name="mark_all_known_as_read">தெரிந்தவற்றைப் படித்ததாகக் குறி</string>
<string name="mark_all_new_as_read">புதியனவற்றைப் படித்ததாகக் குறி</string>
<string name="mark_all_as_read">அனைத்தையும் படித்ததாகக் குறி</string>
<string name="backup_keys">காப்புச் சாவிகள்</string>
<string name="account_backup_tips_md">
## முக்கிய காப்புப்பிரதி மற்றும் பாதுகாப்பு உதவிக்குறிப்புகள்
\n\n உங்கள் கணக்கு ஒரு ரகசியசாவியால் பாதுகாக்கப்படுகிறது. சாவி **nsec1** என்று தொடங்கும் நீண்ட சீரற்ற எழுத்து வடிவம். உங்கள் ரகசிய சாவியை அணுகக்கூடிய எவரும் உங்கள் அடையாளத்தைப் பயன்படுத்தி உள்ளடக்கத்தை வெளியிடலாம்.
@@ -207,9 +207,9 @@
<string name="quick_action_select">தேர்ந்தெடுக்கவும்</string>
<string name="quick_action_share_browser_link">உலாவி இணைப்பைப் பகிரவும்</string>
<string name="quick_action_share">பகிர்</string>
<string name="quick_action_copy_user_id">ஆசிரியர் ஐடி</string>
<string name="quick_action_copy_note_id">குறிப்பு ஐடி</string>
<string name="quick_action_copy_text">உரையை நகலெடுக்கவும்</string>
<string name="quick_action_copy_user_id">பயனர் ஐடி</string>
<string name="quick_action_copy_note_id">பதிவின் எண்</string>
<string name="quick_action_copy_text">உரையைப் படியெடு</string>
<string name="quick_action_delete">அழி</string>
<string name="quick_action_unfollow">பின்தொடர்வதை நிறுத்து</string>
<string name="quick_action_follow">பின்தொடர்</string>
@@ -220,20 +220,20 @@
<string name="quick_action_block">தடைசெய்</string>
<string name="quick_action_report">புகார் </string>
<string name="quick_action_delete_button">அழி</string>
<string name="quick_action_dont_show_again_button">மீண்டும் காட்ட வேண்டாம்</string>
<string name="report_dialog_spam">ஸ்பேம் அல்லது மோசடிகள்</string>
<string name="quick_action_dont_show_again_button">மீண்டும் காட்டாதே</string>
<string name="report_dialog_spam">எரிதம் அல்லது மோசடிகள்</string>
<string name="report_dialog_profanity">அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை</string>
<string name="report_dialog_impersonation">தீங்கிழைக்கும் ஆள்மாறாட்டம்</string>
<string name="report_dialog_nudity">நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம்</string>
<string name="report_dialog_illegal">சட்டவிரோத நடத்தை</string>
<string name="report_dialog_blocking_a_user">ஒரு யூஸரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட யூஸர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
<string name="report_dialog_block_hide_user_btn">யூஸரைத் தடுத்து மறைக்கவும்</string>
<string name="report_dialog_report_btn">தகாதது என பதிவுசெய்</string>
<string name="report_dialog_reminder_public">இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும்.</string>
<string name="report_dialog_nudity">பிறந்தமேனி / தகாத உள்ளடக்கம்</string>
<string name="report_dialog_illegal">சட்டப்புறம்பான நடத்தை</string>
<string name="report_dialog_blocking_a_user">ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் பதிவுகள் இன்னும் வெளிப்படையாகக் காணப்படுகின்றன. தடுக்கப்பட்டப் பயனர்கள், பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
<string name="report_dialog_block_hide_user_btn">பயனரைத் தடுத்து மறை</string>
<string name="report_dialog_report_btn">தகாதது எனக் குறி</string>
<string name="report_dialog_reminder_public">இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் வெளிப்படையாகத் தெரியும்.</string>
<string name="report_dialog_additional_reason_placeholder">உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்…</string>
<string name="report_dialog_additional_reason_label">கூடுதல் சூழல்</string>
<string name="report_dialog_select_reason_label">காரணம்</string>
<string name="report_dialog_select_reason_placeholder">ஒரு காரணத்தைத் தேர்ந்தெடுக்கவும்</string>
<string name="report_dialog_select_reason_placeholder">ஒரு காரணத்தைத் தெரிவு செய்க</string>
<string name="report_dialog_post_report_btn">புகார் அளிக்கவும்</string>
<string name="report_dialog_title">தடு மற்றும் புகாரளி</string>
<string name="block_only">தடு</string>
@@ -249,7 +249,7 @@
<string name="wallet_connect_service_pubkey">பணப்பை இணைப்பு பொது சாவி</string>
<string name="wallet_connect_service_relay">பணப்பை இணைப்பு ரிலே</string>
<string name="wallet_connect_service_secret">பணப்பை இணைப்பு ரகசியம்</string>
<string name="wallet_connect_service_show_secret">ரகசிய சாவியைக் காட்டு</string>
<string name="wallet_connect_service_show_secret">மறைமுகச் சாவியைக் காட்டு</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex தனிப்பட்ட சாவி</string>
<string name="pledge_amount_in_sats">உறுதிமொழி தொகை (ஸாட்களில்)</string>
<string name="post_poll"></string>
@@ -257,34 +257,34 @@
<string name="poll_zap_recipients">ஜாப் பெறுநர்கள்</string>
<string name="poll_primary_description">முதன்மை வாக்கெடுப்பு விளக்கம்…</string>
<string name="poll_option_index">விருப்பம் %s</string>
<string name="poll_option_description">வாக்கெடுப்பு விருப்ப விளக்கம்</string>
<string name="poll_option_description">(வாக்குத்) தெரிவின் விளக்கம்</string>
<string name="poll_heading_optional">விருப்ப புலங்கள்:</string>
<string name="poll_zap_value_min">குறைந்தபட்ச ஜாப்</string>
<string name="poll_zap_value_max">அதிகபட்ச ஜாப்</string>
<string name="poll_zap_value_min">குறைந்தளவு ஜாப்</string>
<string name="poll_zap_value_max">அதிகளவு ஜாப்</string>
<string name="poll_consensus_threshold">ஒருமித்த கருத்து</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">நிறைவு செய்வது</string>
<string name="poll_closing_time">நிறைவுறும் நேரம்</string>
<string name="poll_closing_time_days">நாட்களில்</string>
<string name="poll_is_closed">புதிய வாக்குகளுக்கு வாக்கெடுப்பு மூடப்பட்டுள்ளது</string>
<string name="poll_is_closed">வாக்கெடுப்பு நிறுத்திவைக்கப்பட்டுள்ளது</string>
<string name="poll_zap_amount">ஜாப் தொகை</string>
<string name="one_vote_per_user_on_atomic_votes">இந்த வகை வாக்கெடுப்பில் ஒரு யூஸருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது</string>
<string name="one_vote_per_user_on_atomic_votes">இந்த வகை வாக்கெடுப்பில் ஒரு பயனருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது</string>
<string name="looking_for_event">"நிகழ்வு %1$s ஐத் தேடி"</string>
<string name="custom_zaps_add_a_message">ஒரு பொது செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_private">ஒரு தனிப்பட்ட செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_nonzap">விலைப்பட்டியல் செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_example">ங்கள் எல்லா வேலைகளுக்கும் நன்றி!</string>
<string name="lightning_create_and_add_invoice">உருவாக்கி சேர்க்கவும்</string>
<string name="poll_author_no_vote">வாக்கெடுப்பு ஆசிரியர்கள் தங்கள் சொந்த வாக்கெடுப்பில் வாக்களிக்க முடியாது.</string>
<string name="hash_verification_passed">குறிப்பு பதிவு செய்யப்பட்ட பின்னர் படம் மாற்றப்படவில்லை</string>
<string name="hash_verification_failed">படம் மாறிவிட்டது. மாற்றத்தை ஆசிரியர் பார்த்திருக்க மாட்டார்</string>
<string name="custom_zaps_add_a_message_example">ங்களின் அனைத்துப் பணிகளுக்கும் நன்றி!</string>
<string name="lightning_create_and_add_invoice">உருவாக்கிச் சேர்</string>
<string name="poll_author_no_vote">வாக்கெடுப்பை உருவாக்கியோர் தங்கள் சொந்த வாக்கெடுப்பில் வாக்களிக்க முடியாது.</string>
<string name="hash_verification_passed">பதிவு பதியப்பட்ட பின்னர் படம் மாற்றப்படவில்லை</string>
<string name="hash_verification_failed">படம் மாறிவிட்டது. மாற்றத்தை பயனர் பார்த்திருக்க மாட்டார்</string>
<string name="content_description_add_image">படத்தைச் சேர்க்கவும்</string>
<string name="content_description_add_video">வீடியோ சேர்க்கவும்</string>
<string name="content_description_add_video">காணொளியைச் சேர்க்கவும்</string>
<string name="content_description_add_document">ஆவணத்தைச் சேர்க்கவும்</string>
<string name="add_content">உருவாக்கி சேர்க்கவும்</string>
<string name="content_description">உள்ளடக்கங்களின் விளக்கம்</string>
<string name="content_description">உள்ளடக்க விளக்கம்</string>
<string name="content_description_example">சூரியன் மறையும் வேளையில் வெள்ளை மண் நிறைந்த கடற்கரையில் ஒரு நீலப் படகு</string>
<string name="zap_type">ஜாப் டைப்</string>
<string name="zap_type_explainer">அனைத்து வகைகளுக்கான ஜாப் டைப்</string>
<string name="zap_type">ஜாப் வகை</string>
<string name="zap_type_explainer">அனைத்து வகைமைக்குமான ஜாப்</string>
<string name="zap_type_public">பொது</string>
<string name="zap_type_public_explainer">அனைவரும் ஜாப் பரிவர்த்தனையையும் செய்தியையும் பார்க்கமுடியும்</string>
@@ -299,39 +299,39 @@
<string name="zap_type_nonzap_explainer">நாஸ்டரில் எந்த சுவடும் இன்றி, லைட்நிங்கில் மட்டும் உள்ளது</string>
<string name="file_server">ஃபைல் சர்வர</string>
<string name="zap_forward_lnAddress">LN முகவரி அல்லது @யூஸர்</string>
<string name="file_server">கோப்புச் சேவையகம</string>
<string name="zap_forward_lnAddress">LN முகவரி அல்லது @பயனர்</string>
<string name="upload_server_imgur">imgur.com - நம்பகமானது</string>
<string name="upload_server_imgur_explainer">imgur ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_imgur_explainer">imgur கோப்பை மாற்றமுடியும்</string>
<string name="upload_server_nostrimg">nostrimg.com - நம்பகமானது</string>
<string name="upload_server_nostrimg_explainer">NostrImg ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrimg_explainer">NostrImg கோப்பை மாற்றமுடியும்</string>
<string name="upload_server_nostrbuild">nostr.build - நம்பகமானது</string>
<string name="upload_server_nostrbuild_explainer">nostr.build ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrbuild_explainer">nostr.build கோப்பை மாற்றமுடியும்</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - நம்பகமானது</string>
<string name="upload_server_nostrfilesdev_explainer">nostrfiles.dev ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrfilesdev_explainer">nostrfiles.dev கோப்பை மாற்றமுடியும்</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - நம்பகமானது</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me கோப்பை மாற்றமுடியும்</string>
<string name="upload_server_imgur_nip94">சரிபார்க்கக்கூடிய Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Imgur ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_imgur_nip94_explainer">Imgur கோப்பை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrimg_nip94">சரிபார்க்கக்கூடிய NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">NostrImg ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrimg_nip94_explainer">NostrImg கோப்பை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrbuild_nip94">சரிபார்க்கக்கூடிய Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Nostr.build ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrbuild_nip94_explainer">Nostr.build கோப்பை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrfilesdev_nip94">சரிபார்க்கக்கூடிய Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Nostrfiles.dev ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Nostrfiles.dev கோப்பை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrcheckme_nip94">சரிபார்க்கக்கூடிய Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Nostrcheck.me ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Nostrcheck.me கோப்பை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
@@ -339,16 +339,16 @@
<string name="upload_server_relays_nip95">உங்கள் ரிலே (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">ஃபைல்ஸ் உங்கள் ரிலேக்களில் ஹோஸ்ட் செய்யப்படும். புதிய NIP: ரிலேயில் இச்சேவை உள்ளதா என சரிபார்க்கவும்</string>
<string name="connect_via_tor_short">Tor/Orbot அமைப்பு முறை</string>
<string name="connect_via_tor_short">Tor/Orbot இணைப்பு</string>
<string name="connect_via_tor">Orbot அமைப்பு மூலம் இணைக்கவும்</string>
<string name="do_you_really_want_to_disable_tor_title">Tor/Orbot இணைப்பை துண்டிக்கவும்?</string>
<string name="do_you_really_want_to_disable_tor_title">Tor/Orbot இணைப்பை துண்டிக்கவ?</string>
<string name="do_you_really_want_to_disable_tor_text">உங்கள் டேட்டா உடனே வழக்கமான நெட்வொர்க்கிற்கு மாற்றப்படும்</string>
<string name="yes">ஆம்</string>
<string name="no">இல்லை</string>
<string name="follow_list_selection">பின்பற்றப் படுவோர் பட்டியல்</string>
<string name="follow_list_selection">பின்பற்றப்படுவோர் பட்டியல்</string>
<string name="follow_list_kind3follows">எல்லா பின்பற்றப் படுவோர்</string>
<string name="follow_list_global">முழுதளாவிய</string>
<string name="connect_through_your_orbot_setup_markdown">
@@ -377,7 +377,7 @@
<string name="reply_notify">தெரிவிக்க</string>
<string name="channel_list_join_conversation">உரையாடலில் இணைக</string>
<string name="channel_list_user_or_group_id">யூஸர் அல்லது குழுவின் ID</string>
<string name="channel_list_user_or_group_id">பயனர் அல்லது குழுவின் ID</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent அல்லது hex</string>
<string name="channel_list_create_channel">உருவாக்குக</string>
<string name="channel_list_join_channel">இணைக</string>
@@ -404,41 +404,40 @@
<string name="sats_to_complete">ஜாப்-திரட்டுதல் %1$s. இலக்கை அடைய %2$s</string>
<string name="read_from_relay">படிப்பதற்கான ரிலே</string>
<string name="write_to_relay">போஸ்ட் செய்வதற்கான ரிலே</string>
<string name="an_error_ocurred_trying_to_get_relay_information">%1$s ரிலே விவரங்கள் பெறுகையில் பிழை</string>
<string name="owner">உரிமையாளர்</string>
<string name="version">பதிப்பு</string>
<string name="software">மென்பொருள்</string>
<string name="contact">தொடர்புக்கு</string>
<string name="supports">ஆதரிக்கப்படும் NIPகள்</string>
<string name="admission_fees">அனுமதி கட்டணம்</string>
<string name="admission_fees">நுழைவுக் கட்டணம்</string>
<string name="payments_url">கட்டண URL</string>
<string name="limitations">வரம்புகள்</string>
<string name="countries">நாடுகள்</string>
<string name="languages">மொழிகள்</string>
<string name="tags">டாகுகள்</string>
<string name="posting_policy">ோஸ்ட் செய்வதற்கான கொள்கை</string>
<string name="tags">சிட்டைகள்</string>
<string name="posting_policy">திவுக் கொள்கை</string>
<string name="message_length">குறிப்பு நீளம்</string>
<string name="subscriptions">சப்ஸ்க்ரிப்ஷன்கள்</string>
<string name="filters">வடிப்பான்கள்</string>
<string name="subscription_id_length">சப்ஸ்க்ரிப்ஷன் ID நீளம்</string>
<string name="minimum_prefix">குறைந்தபட்ச முன்னீடு</string>
<string name="maximum_event_tags">அதிகபட்ச நிகழ்வு டாகுகள்</string>
<string name="minimum_prefix">குறைந்தளவு முன்னீடு</string>
<string name="maximum_event_tags">அதிகளவு நிகழ்வு சிட்டைகள்</string>
<string name="content_length">உள்ளடக்க நீளம்</string>
<string name="minimum_pow">குறைந்தபட்ச PoW</string>
<string name="minimum_pow">குறைந்தளவு PoW</string>
<string name="auth">சான்றளிப்பு</string>
<string name="payment">கட்டணம்</string>
<string name="cashu">Cashu டோக்கன்</string>
<string name="cashu">Cashu வில்லை</string>
<string name="cashu_redeem">மீட்க</string>
<string name="live_stream_live_tag">நேரலை</string>
<string name="live_stream_offline_tag">ஆஃப்லைன்</string>
<string name="live_stream_ended_tag">முடிந்தது</string>
<string name="live_stream_offline_tag">இணைப்பிலா நிலை</string>
<string name="live_stream_ended_tag">நிறைவுற்றது</string>
<string name="live_stream_planned_tag">வரவிருக்கிறது</string>
<string name="live_stream_is_offline">லைவ்ஸ்ட்ரீம் ஆஃப்லைனில் உள்ளது</string>
<string name="live_stream_has_ended">லைவ்ஸ்ட்ரீம் முடிந்துவிட்டது</string>
<string name="are_you_sure_you_want_to_log_out">வெளியேறு உங்கள் அனைத்து லோக்கல் விவரங்களையும் நீக்கிவிடும். வெளியேறும் முன் உங்கள் அக்கவுண்ட் தொலையாமல் இருக்க ரகசியசாவியை பத்திரமாக பிரதி செய்து சேமிக்கப்பட்டுள்ளதா என்று சரிபார்க்கவும். வெளியேற்றம் தொடர வேண்டுமா?</string>
<string name="followed_tags">பின்பற்றப்படும் டாகுகள்</string>
<string name="live_stream_is_offline">நேரலை இணைப்பிலாமல் உள்ளது</string>
<string name="live_stream_has_ended">நேரலை நிறைவுற்றது</string>
<string name="are_you_sure_you_want_to_log_out">வெளியேறுவதல் உங்கள் அனைத்து விவரங்களையும் நீக்கிவிடும். வெளியேறும் முன் உங்கள் கணக்கைத் தொலையாமல் இருக்க ரகசியசாவியை பத்திரமாக பிரதி செய்து சேமிக்கப்பட்டுள்ளதா என்று சரிபார்க்கவும். வெளியேற வேண்டுமா?</string>
<string name="followed_tags">பின்பற்றப்படும் சிட்டைகள்</string>
<string name="relay_setup">ரிலேகள்</string>
+7 -2
View File
@@ -48,6 +48,8 @@
<string name="and">" and "</string>
<string name="in_channel">"in channel "</string>
<string name="profile_banner">Profile Banner</string>
<string name="payment_successful">Payment Successful</string>
<string name="error_parsing_error_message">Error parsing error message</string>
<string name="following">" Following"</string>
<string name="followers">" Followers"</string>
<string name="profile">Profile</string>
@@ -433,7 +435,7 @@
<string name="sats_to_complete">Zapraiser at %1$s. %2$s sats to goal</string>
<string name="read_from_relay">Read from Relay</string>
<string name="write_to_relay">Write to Relay</string>
<string name="an_error_ocurred_trying_to_get_relay_information">An error ocurred trying to get relay information from %1$s</string>
<string name="an_error_occurred_trying_to_get_relay_information">An error occurred trying to get relay information from %1$s</string>
<string name="owner">Owner</string>
<string name="version">Version</string>
<string name="software">Software</string>
@@ -458,6 +460,8 @@
<string name="payment">Payment</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Redeem</string>
<string name="no_lightning_address_set">No Lightning Address set</string>
<string name="copied_token_to_clipboard">Copied token to clipboard</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
@@ -508,12 +512,13 @@
<string name="nip05_checking">Checking Nostr address</string>
<string name="select_deselect_all">Select/Deselect all</string>
<string name="default_relays">Default</string>
<string name="select_a_relay_to_continue">Select a relay to continue</string>
<string name="zap_forward_title">Forward Zaps to:</string>
<string name="zap_forward_explainer">Supporting clients will forward zaps to the LNAddress or User Profile below instead of yours</string>
<string name="geohash_title">Expose Location as </string>
<string name="geohash_explainer">Adds a Geohash of your location to the post. People will know you are within 5x5km (3x3mi) or this hash</string>
<string name="geohash_explainer">Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location</string>
<string name="add_sensitive_content_explainer">Adds sensitive content warning before showing your content. This is ideal for any NSFW content or content some people may find offensive or disturbing</string>
</resources>
@@ -26,7 +26,7 @@ data class ResultOrError(
object LanguageTranslatorService {
var executorService = Executors.newScheduledThreadPool(5)
private val options = LanguageIdentificationOptions.Builder().setExecutor(executorService).setConfidenceThreshold(0.52f).build()
private val options = LanguageIdentificationOptions.Builder().setExecutor(executorService).setConfidenceThreshold(0.6f).build()
private val languageIdentification = LanguageIdentification.getClient(options)
val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE)
val tagRegex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", Pattern.CASE_INSENSITIVE)
+10 -8
View File
@@ -1,13 +1,15 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
buildscript {
ext {
fragment_version = "1.6.0"
fragment_version = "1.6.1"
lifecycle_version = '2.6.1'
compose_ui_version = '1.4.3'
nav_version = "2.6.0"
room_version = "2.4.3"
accompanist_version = '0.30.1'
coil_version = '2.4.0'
vico_version = '1.7.1'
vico_version = '1.7.3'
exoplayer_version = '1.1.0'
}
dependencies {
@@ -15,13 +17,13 @@ buildscript {
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.0.2' apply false
id 'com.android.library' version '8.0.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.21' apply false
id 'org.jetbrains.kotlin.jvm' version '1.8.21' apply false
id 'com.android.application' version '8.1.0' apply false
id 'com.android.library' version '8.1.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
id 'org.jetbrains.kotlin.jvm' version '1.9.0' apply false
}
task installGitHook(type: Copy) {
tasks.register('installGitHook', Copy) {
from new File(rootProject.rootDir, 'git-hooks/pre-commit')
from new File(rootProject.rootDir, 'git-hooks/pre-push')
into { new File(rootProject.rootDir, '.git/hooks') }
@@ -30,7 +32,7 @@ task installGitHook(type: Copy) {
tasks.getByPath(':app:preBuild').dependsOn installGitHook
subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
tasks.withType(KotlinCompile).configureEach {
kotlinOptions {
if (project.findProperty("composeCompilerReports") == "true") {
freeCompilerArgs += [