mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 22:54:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0748f4a7 | ||
|
|
1ec4823122 | ||
|
|
cce5e0bf21 | ||
|
|
0006595786 | ||
|
|
6b884f645e | ||
|
|
946723a80e | ||
|
|
791debbaca | ||
|
|
3bb5a4e50d | ||
|
|
3efa3a68fe | ||
|
|
dc314a94ff | ||
|
|
11b062c41f | ||
|
|
faeb2a3894 | ||
|
|
b1f45a346d | ||
|
|
0b16de6a85 | ||
|
|
66d4b75373 | ||
|
|
3b376a615c | ||
|
|
c8da570c89 | ||
|
|
38f4a5b7fc | ||
|
|
b03ff36cf3 | ||
|
|
d145938652 | ||
|
|
1993388d9f | ||
|
|
15e7540b26 | ||
|
|
c2beaf5f80 | ||
|
|
5d395419ff | ||
|
|
f672a51470 | ||
|
|
f741239e23 | ||
|
|
3442194b01 | ||
|
|
0b025d7679 | ||
|
|
227de74317 | ||
|
|
0b9fced8bc | ||
|
|
274e4f7498 | ||
|
|
f6496b5e7e | ||
|
|
ee5ff1845f | ||
|
|
c84e835ce5 | ||
|
|
cdb5be2fe9 | ||
|
|
2d0c356aa5 | ||
|
|
0a92c253b8 | ||
|
|
92347863bc | ||
|
|
a146ec5e1a | ||
|
|
83be43e94e | ||
|
|
d726b3b5b5 | ||
|
|
c845aac8e5 | ||
|
|
1cdf114fff | ||
|
|
4d1a99d076 | ||
|
|
b1debd9879 | ||
|
|
3843917bd1 | ||
|
|
2fa4281b6f | ||
|
|
c42944e15b | ||
|
|
9f9f158709 | ||
|
|
3a753f0d5d | ||
|
|
13c94112d4 | ||
|
|
dd46f12a05 | ||
|
|
9a648d7e84 | ||
|
|
614dbab1fd | ||
|
|
3d80ee1984 | ||
|
|
536fb49bc3 | ||
|
|
edd93141bc | ||
|
|
20c2d19a9c | ||
|
|
17b2fe9cd8 | ||
|
|
134e7cee0c | ||
|
|
108ddffcc7 | ||
|
|
20e76daff8 | ||
|
|
6876a7f0d2 | ||
|
|
87dbf86129 | ||
|
|
175d1ae33b | ||
|
|
bf9d318209 | ||
|
|
7bee44c5ad | ||
|
|
a32e90aed0 | ||
|
|
310db1008e | ||
|
|
21560d098e | ||
|
|
33f7c5c82e | ||
|
|
52f17e9f46 | ||
|
|
1e7ffa365b | ||
|
|
e00fc83e5c | ||
|
|
a2fcb3539e | ||
|
|
cb69d056b8 | ||
|
|
e7f950cd32 | ||
|
|
3b2686ffeb | ||
|
|
2387a609df | ||
|
|
6457a84f4b | ||
|
|
dee231d43a | ||
|
|
8c4af4ac85 | ||
|
|
58cf0d9f5d | ||
|
|
de5c0d4681 | ||
|
|
780832d785 | ||
|
|
b8808a17b5 | ||
|
|
5001e58c2b | ||
|
|
95514b66ea | ||
|
|
90c6d6c8f8 | ||
|
|
88d2ce3ad0 | ||
|
|
484f3a6842 | ||
|
|
866caf8bc5 | ||
|
|
be9e3c3bf7 | ||
|
|
49a3cb1978 | ||
|
|
c3ddc4f7ad | ||
|
|
ff4d704bde | ||
|
|
a773be12c4 | ||
|
|
7ff6f16df0 | ||
|
|
d9053f580f | ||
|
|
fee2764338 | ||
|
|
61cb4fdd74 | ||
|
|
d89869ebff | ||
|
|
8518819dfa | ||
|
|
0a8923be6b | ||
|
|
cda821e5af | ||
|
|
be2402702f | ||
|
|
d723a8ab5c | ||
|
|
5c882c81c8 | ||
|
|
24d2709564 | ||
|
|
ce15bc46a7 | ||
|
|
de4bc30730 | ||
|
|
64f6811c00 | ||
|
|
e05ab104b9 | ||
|
|
b4f1c4d13e | ||
|
|
36a3c4123c | ||
|
|
939eb1bd8d | ||
|
|
854469cc7d | ||
|
|
338ae41baa | ||
|
|
6cae89d276 | ||
|
|
15c1e25122 | ||
|
|
c5716416b6 | ||
|
|
65e4a766ab | ||
|
|
c2a4c9bbb3 | ||
|
|
b042110154 | ||
|
|
da97b5593b | ||
|
|
44b4b0cdb9 | ||
|
|
190b7fe819 | ||
|
|
8eb89f0fe9 | ||
|
|
3085353729 | ||
|
|
1465ce112c | ||
|
|
5c797858ba | ||
|
|
8c51c4be0c | ||
|
|
4e8ecff67b | ||
|
|
6303573dd9 | ||
|
|
83f8c51aca | ||
|
|
e675348461 | ||
|
|
9026f5bf07 | ||
|
|
a7aa3705ef | ||
|
|
a64c941f36 | ||
|
|
02f18b5b35 | ||
|
|
db740f4747 | ||
|
|
6a3206b938 | ||
|
|
23414b0bc6 | ||
|
|
0234cfca30 | ||
|
|
f707714611 | ||
|
|
7b90230c45 | ||
|
|
d3468f3eba | ||
|
|
fc9500b45c | ||
|
|
df0b5fcd19 | ||
|
|
59cde5699e | ||
|
|
bd836a9580 | ||
|
|
90aade338c | ||
|
|
40e2dba490 | ||
|
|
d13e8bacec | ||
|
|
d3fa05a4df | ||
|
|
734dd2e119 | ||
|
|
a9ea9ea2ae | ||
|
|
1b521bc140 | ||
|
|
6d691c4741 | ||
|
|
bbba27752d | ||
|
|
d9cf090cc9 | ||
|
|
46effa572c | ||
|
|
f76ab01c11 | ||
|
|
d1cb699027 | ||
|
|
9ee1250f3a | ||
|
|
7a68cf867e | ||
|
|
bc8fa00608 | ||
|
|
329d806ffd | ||
|
|
e040d0f971 | ||
|
|
c223c1372b | ||
|
|
a450dcbac1 | ||
|
|
ee5fbce2ce | ||
|
|
559b88be18 | ||
|
|
dfbc5fa556 | ||
|
|
df1a017eb3 | ||
|
|
b2b7799a0f | ||
|
|
e817f94045 | ||
|
|
46571a6029 | ||
|
|
4f4d689f5a | ||
|
|
a0308938de | ||
|
|
b10e875941 | ||
|
|
632400f52c | ||
|
|
b63479c45f | ||
|
|
7ea6495f28 | ||
|
|
453e63025d | ||
|
|
17ecc98e1e | ||
|
|
f9272f5cfc | ||
|
|
348e07a307 | ||
|
|
2009f5bd7b | ||
|
|
7ea92bc068 | ||
|
|
3b07b5d8dd | ||
|
|
f06e79cfd3 | ||
|
|
584ceb1f0f | ||
|
|
8053119d42 | ||
|
|
b8e97525eb | ||
|
|
66b1e4b15d | ||
|
|
e6f6f64512 | ||
|
|
d2eea9f0ae | ||
|
|
012fdc8cc6 | ||
|
|
74054aab5a | ||
|
|
11908b9e0c | ||
|
|
570cccfc62 | ||
|
|
de9d6877e1 | ||
|
|
c31fa116ca | ||
|
|
10b15226a1 | ||
|
|
7a46376bc1 | ||
|
|
b761aaf970 | ||
|
|
3a08db2dfb | ||
|
|
41fd2e087c | ||
|
|
ba476bcdca |
@@ -112,6 +112,28 @@ jobs:
|
||||
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Play APK arm64-v8a Asset
|
||||
id: upload-release-asset-play-arm64-v8a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Play APK armeabi-v7a Asset
|
||||
id: upload-release-asset-play-armeabi-v7a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
# F-Droid APK
|
||||
- name: Upload F-Droid APK Universal Asset
|
||||
id: upload-release-asset-fdroid-universal-apk
|
||||
@@ -146,6 +168,28 @@ jobs:
|
||||
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload F-Droid APK arm64-v8a Asset
|
||||
id: upload-release-asset-fdroid-arm64-v8a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload F-Droid APK armeabi-v7a Asset
|
||||
id: upload-release-asset-fdroid-armeabi-v7a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
|
||||
|
||||
# Google Play AAB
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@@ -106,7 +106,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
|
||||
This is a native Android app made with Kotlin and Jetpack Compose.
|
||||
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
|
||||
The overall architecture consists in the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
|
||||
The overall architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
|
||||
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
|
||||
|
||||
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
|
||||
@@ -172,7 +172,7 @@ For the Play build:
|
||||
keytool -genkey -v -keystore <my-release-key.keystore> -alias <alias_name> -keyalg RSA -keysize 2048 -validity 10000
|
||||
openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.jks.base64.txt
|
||||
```
|
||||
2. Create 4 Secret Key variables on your GitHub repository and fill in with the signing key information
|
||||
2. Create four Secret Key variables on your GitHub repository and fill in the signing key information
|
||||
- `KEY_ALIAS` <- `<alias_name>`
|
||||
- `KEY_PASSWORD` <- `<your password>`
|
||||
- `KEY_STORE_PASSWORD` <- `<your key store password>`
|
||||
@@ -192,15 +192,15 @@ The relay also learns which public keys you are requesting, meaning your public
|
||||
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs.
|
||||
|
||||
# DM Privacy #
|
||||
While the content of direct messages (DMs) is only visible to you, and your DM nostr counterparty, everyone can see that and when you and your counterparty are DM-ing each other.
|
||||
While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
|
||||
|
||||
# Visibility & Permanence of Your Content on nostr
|
||||
## Information Visibility ##
|
||||
Content that you share can be shared to other relays.
|
||||
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
|
||||
Information that you share publicly is visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
|
||||
|
||||
## Information Permanence ##
|
||||
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
|
||||
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
|
||||
|
||||
# Screenshots
|
||||
|
||||
|
||||
+5
-5
@@ -13,14 +13,14 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 292
|
||||
versionName "0.75.13"
|
||||
versionCode 300
|
||||
versionName "0.77.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
|
||||
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'in', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -54,7 +54,7 @@ android {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include "x86", "x86_64"
|
||||
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
// Zoomable images
|
||||
implementation "net.engawapg.lib:zoomable:1.5.0"
|
||||
implementation 'net.engawapg.lib:zoomable:1.5.1'
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.RichTextParser
|
||||
import com.vitorpamplona.amethyst.service.RichTextViewerState
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -666,7 +666,7 @@ class RichTextParserTest {
|
||||
|
||||
@Test
|
||||
fun testTextToParse() {
|
||||
val state = RichTextParser().parseText(textToParse, ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText(textToParse, EmptyTagList)
|
||||
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, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl",
|
||||
state.urlSet.joinToString(", ")
|
||||
@@ -3989,7 +3989,7 @@ class RichTextParserTest {
|
||||
|
||||
@Test
|
||||
fun testShortTextToParse() {
|
||||
val state = RichTextParser().parseText("Hi, how are you doing? ", ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText("Hi, how are you doing? ", EmptyTagList)
|
||||
Assert.assertTrue(state.urlSet.isEmpty())
|
||||
Assert.assertTrue(state.imagesForPager.isEmpty())
|
||||
Assert.assertTrue(state.imageList.isEmpty())
|
||||
@@ -3999,7 +3999,7 @@ class RichTextParserTest {
|
||||
|
||||
@Test
|
||||
fun testShortNewLinesTextToParse() {
|
||||
val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList)
|
||||
Assert.assertTrue(state.urlSet.isEmpty())
|
||||
Assert.assertTrue(state.imagesForPager.isEmpty())
|
||||
Assert.assertTrue(state.imageList.isEmpty())
|
||||
@@ -4019,7 +4019,7 @@ See how it can be done here: https://lnshort.it/live-stream-embeds/
|
||||
https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg
|
||||
""".trimIndent()
|
||||
|
||||
val state = RichTextParser().parseText(text, ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText(text, EmptyTagList)
|
||||
Assert.assertEquals("https://lnshort.it/live-stream-embeds/", state.urlSet.firstOrNull())
|
||||
Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imagesForPager.keys.firstOrNull())
|
||||
Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imageList.firstOrNull()?.url)
|
||||
@@ -4080,7 +4080,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
fun testNewLineAfterImage() {
|
||||
val text = "That’s it ! That’s the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg "
|
||||
|
||||
val state = RichTextParser().parseText(text, ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText(text, EmptyTagList)
|
||||
|
||||
printStateForDebug(state)
|
||||
|
||||
@@ -4106,7 +4106,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
fun testSapceAfterImage() {
|
||||
val text = "That’s it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg That’s the #note"
|
||||
|
||||
val state = RichTextParser().parseText(text, ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText(text, EmptyTagList)
|
||||
|
||||
printStateForDebug(state)
|
||||
|
||||
|
||||
+3
-2
@@ -2,7 +2,8 @@ package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
|
||||
class PushNotificationUtils {
|
||||
fun init(accounts: List<AccountInfo>) {
|
||||
object PushNotificationUtils {
|
||||
var hasInit: Boolean = true
|
||||
suspend fun init(accounts: List<AccountInfo>) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<queries>
|
||||
<package android:name="org.torproject.android"/>
|
||||
<package android:name="com.greenart7c3.nostrsigner"/>
|
||||
<package android:name="com.greenart7c3.nostrsigner.debug"/>
|
||||
</queries>
|
||||
|
||||
<!-- Doesn't require a camera -->
|
||||
|
||||
@@ -3,10 +3,13 @@ package com.vitorpamplona.amethyst
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.DefaultReactions
|
||||
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
@@ -16,10 +19,8 @@ import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
@@ -35,7 +36,8 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
|
||||
@Immutable
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean = false
|
||||
val hasPrivKey: Boolean,
|
||||
val loggedInWithExternalSigner: Boolean
|
||||
)
|
||||
|
||||
private object PrefKeys {
|
||||
@@ -74,7 +76,7 @@ private object PrefKeys {
|
||||
const val THEME = "theme"
|
||||
const val PREFERRED_LANGUAGE = "preferred_Language"
|
||||
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
|
||||
}
|
||||
|
||||
object LocalPreferences {
|
||||
@@ -82,7 +84,7 @@ object LocalPreferences {
|
||||
|
||||
private var _currentAccount: String? = null
|
||||
|
||||
private fun currentAccount(): String? {
|
||||
fun currentAccount(): String? {
|
||||
if (_currentAccount == null) {
|
||||
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
}
|
||||
@@ -201,7 +203,11 @@ object LocalPreferences {
|
||||
|
||||
fun allSavedAccounts(): List<AccountInfo> {
|
||||
return savedAccounts().map { npub ->
|
||||
AccountInfo(npub = npub)
|
||||
AccountInfo(
|
||||
npub,
|
||||
hasPrivKey(npub),
|
||||
getLoggedInWithExternalSigner(npub)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +251,7 @@ object LocalPreferences {
|
||||
} else {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
|
||||
}
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.loginWithExternalSigner)
|
||||
}.apply()
|
||||
|
||||
val globalPrefs = encryptedPreferences()
|
||||
@@ -276,17 +283,19 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
fun getTheme(): Int {
|
||||
encryptedPreferences().apply {
|
||||
return getInt(PrefKeys.THEME, 0)
|
||||
}
|
||||
return encryptedPreferences().getInt(PrefKeys.THEME, 0)
|
||||
}
|
||||
|
||||
fun getPreferredLanguage(): String {
|
||||
var language = ""
|
||||
encryptedPreferences().apply {
|
||||
language = getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
|
||||
}
|
||||
return language
|
||||
return encryptedPreferences().getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
|
||||
}
|
||||
|
||||
private fun getLoggedInWithExternalSigner(npub: String): Boolean {
|
||||
return encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
|
||||
}
|
||||
|
||||
private fun hasPrivKey(npub: String): Boolean {
|
||||
return (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
@@ -316,11 +325,11 @@ object LocalPreferences {
|
||||
|
||||
val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let {
|
||||
Event.mapper.readValue<List<Long>?>(it)
|
||||
}?.ifEmpty { listOf(500L, 1000L, 5000L) } ?: listOf(500L, 1000L, 5000L)
|
||||
}?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
|
||||
|
||||
val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let {
|
||||
Event.mapper.readValue<List<String>?>(it)
|
||||
}?.ifEmpty { listOf("+") } ?: listOf("+")
|
||||
}?.ifEmpty { DefaultReactions } ?: DefaultReactions
|
||||
|
||||
val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
|
||||
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
|
||||
@@ -335,16 +344,18 @@ object LocalPreferences {
|
||||
Event.mapper.readValue<Nip47URI?>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", e)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val latestContactList = try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
println("Decoding Contact List: " + it)
|
||||
Event.fromJson(it) as ContactListEvent?
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
Log.w("LocalPreferences", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", e)
|
||||
null
|
||||
}
|
||||
|
||||
@@ -353,6 +364,7 @@ object LocalPreferences {
|
||||
Event.mapper.readValue<Map<String, String>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", e)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
@@ -363,6 +375,7 @@ object LocalPreferences {
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
|
||||
|
||||
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
|
||||
@@ -377,6 +390,7 @@ object LocalPreferences {
|
||||
Event.mapper.readValue<Map<String, Long>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", e)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
@@ -431,51 +445,11 @@ object LocalPreferences {
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
settings = settings
|
||||
settings = settings,
|
||||
loginWithExternalSigner = loginWithExternalSigner
|
||||
)
|
||||
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
fun migrateSingleUserPrefs() {
|
||||
if (currentAccount() != null) return
|
||||
|
||||
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
|
||||
val npub = Hex.decode(pubkey).toNpub()
|
||||
|
||||
val stringPrefs = listOf(
|
||||
PrefKeys.NOSTR_PRIVKEY,
|
||||
PrefKeys.NOSTR_PUBKEY,
|
||||
PrefKeys.RELAYS,
|
||||
PrefKeys.LANGUAGE_PREFS,
|
||||
PrefKeys.TRANSLATE_TO,
|
||||
PrefKeys.ZAP_AMOUNTS,
|
||||
PrefKeys.LATEST_CONTACT_LIST
|
||||
)
|
||||
|
||||
val stringSetPrefs = listOf(
|
||||
PrefKeys.FOLLOWING_CHANNELS,
|
||||
PrefKeys.HIDDEN_USERS,
|
||||
PrefKeys.DONT_TRANSLATE_FROM
|
||||
)
|
||||
|
||||
encryptedPreferences().apply {
|
||||
val appPrefs = this
|
||||
encryptedPreferences(npub).edit().apply {
|
||||
val userPrefs = this
|
||||
|
||||
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
|
||||
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
|
||||
userPrefs.putBoolean(
|
||||
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
|
||||
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
addAccount(npub)
|
||||
updateCurrentAccount(npub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +92,7 @@ class MultiPlayerPlaybackManager(
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
STATE_IDLE -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
STATE_READY -> {
|
||||
cachedPositions.get(uri)?.let { lastPosition ->
|
||||
@@ -104,9 +102,7 @@ class MultiPlayerPlaybackManager(
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
@@ -35,15 +36,21 @@ import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import java.io.File
|
||||
|
||||
object ServiceManager {
|
||||
var shouldPauseService: Boolean = true // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
private var account: Account? = null
|
||||
|
||||
fun start(account: Account, context: Context) {
|
||||
this.account = account
|
||||
ExternalSignerUtils.account = account
|
||||
start(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start(context: Context) {
|
||||
if (isStarted && account != null) {
|
||||
return
|
||||
}
|
||||
Log.d("ServiceManager", "Starting Relay Services")
|
||||
|
||||
val myAccount = account
|
||||
@@ -88,6 +95,7 @@ object ServiceManager {
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
isStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +121,7 @@ object ServiceManager {
|
||||
NostrVideoDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
fun cleanObservers() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -11,6 +13,7 @@ import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.HexValidator
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
@@ -53,6 +56,10 @@ object LocalCache {
|
||||
// checkNotInMainThread()
|
||||
|
||||
return users[key] ?: run {
|
||||
require(isValidHex(key = key)) {
|
||||
"$key is not a valid hex"
|
||||
}
|
||||
|
||||
val newObject = User(key)
|
||||
users.putIfAbsent(key, newObject) ?: newObject
|
||||
}
|
||||
@@ -98,6 +105,10 @@ object LocalCache {
|
||||
checkNotInMainThread()
|
||||
|
||||
return notes.get(idHex) ?: run {
|
||||
require(isValidHex(idHex)) {
|
||||
"$idHex is not a valid hex"
|
||||
}
|
||||
|
||||
val newObject = Note(idHex)
|
||||
notes.putIfAbsent(idHex, newObject) ?: newObject
|
||||
}
|
||||
@@ -198,6 +209,14 @@ object LocalCache {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
|
||||
if (event.dTag() == "bookmark") {
|
||||
val loggedInUser = LocalPreferences.currentAccount()
|
||||
val hexKey = loggedInUser?.bechToBytes()
|
||||
if (hexKey != null) {
|
||||
val pubKey = Hex.encode(hexKey)
|
||||
if (pubKey == event.pubKey) {
|
||||
ExternalSignerUtils.content.remove(event.id)
|
||||
}
|
||||
}
|
||||
user.updateBookmark(event)
|
||||
}
|
||||
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
|
||||
@@ -807,23 +826,23 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
|
||||
val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
|
||||
|
||||
if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) {
|
||||
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
|
||||
return
|
||||
}
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
|
||||
(
|
||||
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
|
||||
(zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
|
||||
)
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
if (zapRequest == null) {
|
||||
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
|
||||
return
|
||||
}
|
||||
|
||||
// Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
repliesTo.forEach {
|
||||
|
||||
@@ -43,6 +43,10 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
|
||||
return minOf(publishedAt, lastCreatedAt)
|
||||
}
|
||||
|
||||
fun dTag(): String? {
|
||||
return (event as? AddressableEvent)?.dTag()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -142,7 +146,7 @@ open class Note(val idHex: String) {
|
||||
* This method caches signatures during each execution to avoid recalculation in longer threads
|
||||
*/
|
||||
fun replyLevelSignature(
|
||||
eventsToConsider: Set<Note>,
|
||||
eventsToConsider: Set<HexKey>,
|
||||
cachedSignatures: MutableMap<Note, LevelSignature>,
|
||||
account: User,
|
||||
accountFollowingSet: Set<String>,
|
||||
@@ -159,7 +163,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
val parent = (
|
||||
replyTo
|
||||
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
|
||||
.filter { it.idHex in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
|
||||
.map {
|
||||
cachedSignatures[it] ?: it.replyLevelSignature(
|
||||
eventsToConsider,
|
||||
@@ -734,6 +738,12 @@ class NoteLiveSet(u: Note) {
|
||||
it.note.boosts.toImmutableList()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val relayInfo = innerRelays.map {
|
||||
it.note.relays.map {
|
||||
RelayBriefInfo(it)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.hasObservers() ||
|
||||
reactions.hasObservers() ||
|
||||
@@ -812,3 +822,10 @@ class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveDat
|
||||
|
||||
@Immutable
|
||||
class NoteState(val note: Note)
|
||||
|
||||
@Immutable
|
||||
data class RelayBriefInfo(
|
||||
val url: String,
|
||||
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
|
||||
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
|
||||
)
|
||||
|
||||
@@ -14,4 +14,6 @@ data class RelaySetupInfo(
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
)
|
||||
) {
|
||||
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.quartz.encoders.Bech32
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
@@ -27,9 +26,6 @@ import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.math.BigDecimal
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
|
||||
|
||||
@Stable
|
||||
class User(val pubkeyHex: String) {
|
||||
@@ -268,19 +264,11 @@ class User(val pubkeyHex: String) {
|
||||
info?.updatedMetadataAt = latestMetadata.createdAt
|
||||
info?.tags = latestMetadata.tags.toImmutableListOfLists()
|
||||
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
|
||||
try {
|
||||
val url = String(Bech32.decodeBytes(newUserInfo.lud06!!, false).second)
|
||||
|
||||
val matcher = lnurlpPattern.matcher(url)
|
||||
while (matcher.find()) {
|
||||
val domain = matcher.group(2)
|
||||
val username = matcher.group(3)
|
||||
|
||||
info?.lud16 = "$username@$domain"
|
||||
if (newUserInfo.lud16.isNullOrBlank()) {
|
||||
info?.lud06?.let {
|
||||
if (it.lowercase().startsWith("lnurl")) {
|
||||
info?.lud16 = Lud06().toLud16(it)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Doesn't create errors.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +319,7 @@ class User(val pubkeyHex: String) {
|
||||
return latestContactList?.verifiedFollowKeySet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingTagSet(): Set<HexKey> {
|
||||
fun cachedFollowingTagSet(): Set<String> {
|
||||
return latestContactList?.verifiedFollowTagSet ?: emptySet()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class SignerType {
|
||||
SIGN_EVENT,
|
||||
NIP04_ENCRYPT,
|
||||
NIP04_DECRYPT,
|
||||
NIP44_ENCRYPT,
|
||||
NIP44_DECRYPT,
|
||||
GET_PUBLIC_KEY,
|
||||
DECRYPT_ZAP_EVENT
|
||||
}
|
||||
|
||||
object ExternalSignerUtils {
|
||||
val content = LruCache<String, String>(10)
|
||||
var isActivityRunning: Boolean = false
|
||||
val cachedDecryptedContent = mutableMapOf<HexKey, String>()
|
||||
lateinit var account: Account
|
||||
private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var decryptResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var blockListResultLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun requestRejectedToast() {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
Amethyst.instance,
|
||||
Amethyst.instance.getString(R.string.sign_request_rejected),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun default() {
|
||||
isActivityRunning = false
|
||||
ServiceManager.shouldPauseService = true
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
isActivityRunning = false
|
||||
ServiceManager.shouldPauseService = true
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun start(activity: MainActivity) {
|
||||
activityResultLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
requestRejectedToast()
|
||||
} else {
|
||||
val event = it.data?.getStringExtra("signature") ?: ""
|
||||
val id = it.data?.getStringExtra("id") ?: ""
|
||||
if (id.isNotBlank()) {
|
||||
content.put(id, event)
|
||||
}
|
||||
}
|
||||
default()
|
||||
}
|
||||
|
||||
decryptResultLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
requestRejectedToast()
|
||||
} else {
|
||||
val event = it.data?.getStringExtra("signature") ?: ""
|
||||
val id = it.data?.getStringExtra("id") ?: ""
|
||||
if (id.isNotBlank()) {
|
||||
content.put(id, event)
|
||||
cachedDecryptedContent[id] = event
|
||||
}
|
||||
}
|
||||
default()
|
||||
}
|
||||
|
||||
blockListResultLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
requestRejectedToast()
|
||||
} else {
|
||||
val decryptedContent = it.data?.getStringExtra("signature") ?: ""
|
||||
val id = it.data?.getStringExtra("id") ?: ""
|
||||
if (id.isNotBlank()) {
|
||||
cachedDecryptedContent[id] = decryptedContent
|
||||
account.live.invalidateData()
|
||||
}
|
||||
}
|
||||
default()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun openSigner(
|
||||
data: String,
|
||||
type: SignerType,
|
||||
intentResult: ActivityResultLauncher<Intent>,
|
||||
pubKey: HexKey,
|
||||
id: String
|
||||
) {
|
||||
try {
|
||||
ServiceManager.shouldPauseService = false
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data"))
|
||||
val signerType = when (type) {
|
||||
SignerType.SIGN_EVENT -> "sign_event"
|
||||
SignerType.NIP04_ENCRYPT -> "nip04_encrypt"
|
||||
SignerType.NIP04_DECRYPT -> "nip04_decrypt"
|
||||
SignerType.NIP44_ENCRYPT -> "nip44_encrypt"
|
||||
SignerType.NIP44_DECRYPT -> "nip44_decrypt"
|
||||
SignerType.GET_PUBLIC_KEY -> "get_public_key"
|
||||
SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event"
|
||||
}
|
||||
intent.putExtra("type", signerType)
|
||||
intent.putExtra("pubKey", pubKey)
|
||||
intent.putExtra("id", id)
|
||||
if (type !== SignerType.GET_PUBLIC_KEY) {
|
||||
intent.putExtra("current_user", account.keyPair.pubKey.toNpub())
|
||||
}
|
||||
intent.`package` = "com.greenart7c3.nostrsigner"
|
||||
intentResult.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Signer", "Error opening Signer app", e)
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
Amethyst.instance,
|
||||
Amethyst.instance.getString(R.string.error_opening_external_signer),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openSigner(event: EventInterface, columnName: String = "signature") {
|
||||
checkNotInMainThread()
|
||||
|
||||
val result = getDataFromResolver(SignerType.SIGN_EVENT, arrayOf(event.toJson(), event.pubKey()), columnName)
|
||||
if (result !== null) {
|
||||
content.put(event.id(), result)
|
||||
return
|
||||
}
|
||||
|
||||
ServiceManager.shouldPauseService = false
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
event.toJson(),
|
||||
SignerType.SIGN_EVENT,
|
||||
activityResultLauncher,
|
||||
"",
|
||||
event.id()
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptBlockList(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result !== null) {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
return
|
||||
}
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
blockListResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
}
|
||||
|
||||
fun getDataFromResolver(signerType: SignerType, data: Array<out String>, columnName: String = "signature"): String? {
|
||||
val localData = if (signerType !== SignerType.GET_PUBLIC_KEY) {
|
||||
data.toList().plus(account.keyPair.pubKey.toNpub()).toTypedArray()
|
||||
} else {
|
||||
data
|
||||
}
|
||||
|
||||
Amethyst.instance.contentResolver.query(
|
||||
Uri.parse("content://com.greenart7c3.nostrsigner.$signerType"),
|
||||
localData,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use {
|
||||
if (it !== null) {
|
||||
if (it.moveToFirst()) {
|
||||
val index = it.getColumnIndex(columnName)
|
||||
if (index < 0) {
|
||||
Log.d("getDataFromResolver", "column '$columnName' not found")
|
||||
return null
|
||||
}
|
||||
return it.getString(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun decrypt(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result !== null) {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
return
|
||||
}
|
||||
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptDM(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result !== null) {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
return
|
||||
}
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
}
|
||||
|
||||
fun decryptBookmark(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result !== null) {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
return
|
||||
}
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
}
|
||||
|
||||
fun encrypt(decryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_ENCRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey))
|
||||
if (result !== null) {
|
||||
content.put(id, result)
|
||||
return
|
||||
}
|
||||
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
decryptedContent,
|
||||
signerType,
|
||||
activityResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptZapEvent(event: LnZapRequestEvent) {
|
||||
val result = getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey))
|
||||
if (result !== null) {
|
||||
content.put(event.id, result)
|
||||
cachedDecryptedContent[event.id] = result
|
||||
return
|
||||
}
|
||||
openSigner(
|
||||
event.toJson(),
|
||||
SignerType.DECRYPT_ZAP_EVENT,
|
||||
decryptResultLauncher,
|
||||
event.pubKey,
|
||||
event.id
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
object Nip11CachedRetriever {
|
||||
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
|
||||
val retriever = Nip11Retriever()
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
|
||||
) {
|
||||
val url = retriever.cleanUrl(dirtyUrl)
|
||||
val doc = relayInformationDocumentCache.get(url)
|
||||
|
||||
if (doc != null) {
|
||||
onInfo(doc)
|
||||
} else {
|
||||
Nip11Retriever().loadRelayInfo(
|
||||
url,
|
||||
dirtyUrl,
|
||||
onInfo = {
|
||||
relayInformationDocumentCache.put(url, it)
|
||||
onInfo(it)
|
||||
},
|
||||
onError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Nip11Retriever {
|
||||
enum class ErrorCode {
|
||||
FAIL_TO_ASSEMBLE_URL,
|
||||
FAIL_TO_REACH_SERVER,
|
||||
FAIL_TO_PARSE_RESULT,
|
||||
FAIL_WITH_HTTP_STATUS
|
||||
}
|
||||
|
||||
fun cleanUrl(dirtyUrl: String): String {
|
||||
return if (dirtyUrl.contains("://")) {
|
||||
dirtyUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
} else {
|
||||
"https://$dirtyUrl"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onError: (String, ErrorCode, String?) -> Unit
|
||||
) {
|
||||
try {
|
||||
val request: Request = Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(RelayInformation.fromJson(body))
|
||||
} else {
|
||||
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,26 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
@@ -143,6 +152,20 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
event.cachedGift(privateKey)?.let {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGift(account.keyPair.pubKey, cached)?.let {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +175,20 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
event.cachedGossip(privateKey)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't store sealed gossips to avoid rebroadcasting by mistake.
|
||||
@@ -181,7 +218,6 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
val event = account.createAuthEvent(relay, challenge)
|
||||
|
||||
if (event != null) {
|
||||
Client.send(
|
||||
event,
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.Error
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
@@ -23,6 +24,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
data class Counter(var counter: Int)
|
||||
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
var changingFilters = AtomicBoolean()
|
||||
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
@@ -59,11 +61,17 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
if (type == Relay.Type.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[subscriptionId]?.updateEOSE(TimeUtils.now(), relay.url)
|
||||
subscriptions[subscriptionId]?.updateEOSE(
|
||||
TimeUtils.fiveMinutesAgo(), // in case people's clock is slighly off.
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
if (success) {
|
||||
markAsSeenOnRelay(eventId, relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
@@ -136,6 +144,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// saves the current content to only update if it changes
|
||||
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
|
||||
|
||||
changingFilters.getAndSet(true)
|
||||
|
||||
updateChannelFilters()
|
||||
|
||||
// Makes sure to only send an updated filter when it actually changes.
|
||||
@@ -167,12 +177,18 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changingFilters.getAndSet(false)
|
||||
}
|
||||
|
||||
open fun consume(event: Event, relay: Relay) {
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
}
|
||||
|
||||
open fun markAsSeenOnRelay(eventId: String, relay: Relay) {
|
||||
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ class NostrLnZapPaymentResponseDataSource(
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
val event = RelayAuthEvent.create(relay.url, challenge, authSigningKey)
|
||||
val event = RelayAuthEvent.create(relay.url, challenge, "", authSigningKey)
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
|
||||
@@ -134,6 +134,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel { time, relayUrl ->
|
||||
// Ignores EOSE if it is in the middle of a filter change.
|
||||
if (changingFilters.get()) return@requestNewChannel
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
eventsToWatch.forEach {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object PackageUtils {
|
||||
private fun isPackageInstalled(context: Context, target: String): Boolean {
|
||||
return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null
|
||||
}
|
||||
|
||||
fun isOrbotInstalled(context: Context): Boolean {
|
||||
return isPackageInstalled(context, "org.torproject.android")
|
||||
}
|
||||
|
||||
fun isAmberInstalled(context: Context): Boolean {
|
||||
return isPackageInstalled(context, "com.greenart7c3.nostrsigner")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.round
|
||||
|
||||
class ZapPaymentHandler(val account: Account) {
|
||||
|
||||
@Immutable
|
||||
data class Payable(
|
||||
val info: ZapSplitSetup,
|
||||
val user: User?,
|
||||
val amountMilliSats: Long,
|
||||
val invoice: String
|
||||
)
|
||||
|
||||
suspend fun zap(
|
||||
note: Note,
|
||||
amountMilliSats: Long,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
context: Context,
|
||||
onError: (String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
zapType: LnZapEvent.ZapType
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val zapSplitSetup = note.event?.zapSplitSetup()
|
||||
|
||||
val zapsToSend = if (!zapSplitSetup.isNullOrEmpty()) {
|
||||
zapSplitSetup
|
||||
} else {
|
||||
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
|
||||
return@withContext
|
||||
}
|
||||
|
||||
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
|
||||
}
|
||||
|
||||
val totalWeight = zapsToSend.sumOf { it.weight }
|
||||
|
||||
val invoicesToPayOnIntent = mutableListOf<Payable>()
|
||||
|
||||
zapsToSend.forEachIndexed { index, value ->
|
||||
val outerProgressMin = index / zapsToSend.size.toFloat()
|
||||
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
|
||||
|
||||
val zapValue =
|
||||
round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
|
||||
|
||||
if (value.isLnAddress) {
|
||||
innerZap(
|
||||
lud16 = value.lnAddressOrPubKeyHex,
|
||||
note = note,
|
||||
amount = zapValue,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
context = context,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
|
||||
},
|
||||
zapType = zapType,
|
||||
onPayInvoiceThroughIntent = {
|
||||
invoicesToPayOnIntent.add(
|
||||
Payable(
|
||||
info = value,
|
||||
user = null,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = it
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
|
||||
if (lud16 != null) {
|
||||
innerZap(
|
||||
lud16 = lud16,
|
||||
note = note,
|
||||
amount = zapValue,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
context = context,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
|
||||
},
|
||||
zapType = zapType,
|
||||
overrideUser = user,
|
||||
onPayInvoiceThroughIntent = {
|
||||
invoicesToPayOnIntent.add(
|
||||
Payable(
|
||||
info = value,
|
||||
user = user,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = it
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
onError(
|
||||
context.getString(
|
||||
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (invoicesToPayOnIntent.isNotEmpty()) {
|
||||
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
|
||||
onProgress(1f)
|
||||
} else {
|
||||
launch(Dispatchers.IO) {
|
||||
// Awaits for the event to come back to LocalCache.
|
||||
delay(5000)
|
||||
onProgress(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerZap(
|
||||
lud16: String,
|
||||
note: Note,
|
||||
amount: Long,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
context: Context,
|
||||
onError: (String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayInvoiceThroughIntent: (String) -> Unit,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
overrideUser: User? = null
|
||||
) {
|
||||
var zapRequestJson = ""
|
||||
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP) {
|
||||
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser)
|
||||
if (zapRequest != null) {
|
||||
zapRequestJson = zapRequest.toJson()
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(0.10f)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount,
|
||||
message,
|
||||
zapRequestJson,
|
||||
onSuccess = {
|
||||
onProgress(0.7f)
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = it,
|
||||
note,
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString()
|
||||
?: "Error parsing error message"
|
||||
)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
}
|
||||
}
|
||||
)
|
||||
onProgress(0.8f)
|
||||
} else {
|
||||
try {
|
||||
onPayInvoiceThroughIntent(it)
|
||||
} catch (e: Exception) {
|
||||
onError(context.getString(R.string.lightning_wallets_not_found2))
|
||||
}
|
||||
onProgress(0f)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = onProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
+22
-29
@@ -4,16 +4,14 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.Bech32
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
import com.vitorpamplona.quartz.encoders.toLnUrl
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.net.URLEncoder
|
||||
|
||||
class LightningAddressResolver() {
|
||||
@@ -27,11 +25,7 @@ class LightningAddressResolver() {
|
||||
}
|
||||
|
||||
if (lnaddress.lowercase().startsWith("lnurl")) {
|
||||
return try {
|
||||
String(Bech32.decodeBytes(lnaddress, false).second)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
return Lud06().toLnUrlp(lnaddress)
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -66,7 +60,7 @@ class LightningAddressResolver() {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
|
||||
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
@@ -82,22 +76,13 @@ class LightningAddressResolver() {
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(response.body.string())
|
||||
} else {
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
}
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
@@ -115,7 +100,7 @@ class LightningAddressResolver() {
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
onSuccess: (String) -> Unit,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit
|
||||
) {
|
||||
@@ -159,14 +144,22 @@ class LightningAddressResolver() {
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val expectedAmountInSats = BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
|
||||
if (invoiceAmount.toLong() == expectedAmountInSats) {
|
||||
onProgress(0.7f)
|
||||
onSuccess(pr)
|
||||
} else {
|
||||
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
|
||||
onProgress(0.0f)
|
||||
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from $lnaddress. It should have been $expectedAmountInSats")
|
||||
}
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
} ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason ->
|
||||
onProgress(0.0f)
|
||||
onError("Unable to create a lightning invoice before sending the zap. The receiver's lightning wallet sent the following error: $reason")
|
||||
} ?: run {
|
||||
onProgress(0.0f)
|
||||
onError("nable to create a lightning invoice before sending the zap. Element pr not found in the resulting JSON.")
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
|
||||
+171
-99
@@ -7,6 +7,8 @@ import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.SignerType
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
@@ -24,40 +26,122 @@ import kotlinx.collections.immutable.persistentSetOf
|
||||
import java.math.BigDecimal
|
||||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
suspend fun consume(event: GiftWrapEvent) {
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (!notificationManager().areNotificationsEnabled()) return
|
||||
|
||||
suspend fun consume(event: Event) {
|
||||
if (LocalCache.notes[event.id] == null) {
|
||||
if (LocalCache.justVerify(event)) {
|
||||
LocalCache.justConsume(event, null)
|
||||
// PushNotification Wraps don't include a receiver.
|
||||
// Test with all logged in accounts
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if (acc != null && (acc.keyPair.privKey != null || acc.loginWithExternalSigner)) {
|
||||
consumeIfMatchesAccount(event, acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val manager = notificationManager()
|
||||
if (manager.areNotificationsEnabled()) {
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
is GiftWrapEvent -> unwrapAndNotify(event)
|
||||
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
|
||||
val key = account.keyPair.privKey
|
||||
if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
pushWrappedEvent.content,
|
||||
pushWrappedEvent.pubKey,
|
||||
pushWrappedEvent.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id] ?: ""
|
||||
}
|
||||
pushWrappedEvent.unwrap(cached)?.let { notificationEvent ->
|
||||
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
|
||||
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
|
||||
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
|
||||
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key != null) {
|
||||
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
|
||||
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
|
||||
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
|
||||
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
|
||||
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unwrapAndConsume(event: Event, account: Account): Event? {
|
||||
private fun unwrapAndConsume(event: Event, account: Account): Event? {
|
||||
if (!LocalCache.justVerify(event)) return null
|
||||
|
||||
return when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
val key = account.keyPair.privKey ?: return null
|
||||
event.cachedGift(key)?.let {
|
||||
unwrapAndConsume(it, account)
|
||||
val key = account.keyPair.privKey
|
||||
if (key != null) {
|
||||
event.cachedGift(key)?.let {
|
||||
unwrapAndConsume(it, account)
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGift(account.keyPair.pubKey, cached)?.let {
|
||||
unwrapAndConsume(it, account)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
val key = account.keyPair.privKey ?: return null
|
||||
event.cachedGossip(key)?.let {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
it
|
||||
val key = account.keyPair.privKey
|
||||
if (key != null) {
|
||||
event.cachedGossip(key)?.let {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
it
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
|
||||
LocalCache.justConsume(it, null)
|
||||
it
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -67,75 +151,67 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unwrapAndNotify(giftWrap: GiftWrapEvent) {
|
||||
val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return
|
||||
private fun notify(event: ChatMessageEvent, acc: Account) {
|
||||
if (event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
|
||||
event.pubKey != acc.userProfile().pubkeyHex
|
||||
) { // from the user
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
val chatNote = LocalCache.notes[event.id] ?: return
|
||||
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == giftWrap.recipientPubKey()) {
|
||||
val chatEvent = unwrapAndConsume(giftWrap, account = acc)
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
if (chatEvent is ChatMessageEvent && // must be messages, not any other event
|
||||
acc.keyPair.privKey != null && // can't decrypt
|
||||
chatEvent.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
|
||||
chatEvent.pubKey != acc.userProfile().pubkeyHex // from the user
|
||||
) {
|
||||
val chatNote = LocalCache.notes[chatEvent.id] ?: return
|
||||
val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||
val isKnownRoom = (
|
||||
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(chatRoom)
|
||||
) && !acc.isAllHidden(chatRoom.users)
|
||||
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val isKnownRoom = (
|
||||
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(chatRoom)
|
||||
) && !acc.isAllHidden(chatRoom.users)
|
||||
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri = chatNote.toNEvent()
|
||||
notificationManager().sendDMNotification(chatEvent.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri = chatNote.toNEvent()
|
||||
notificationManager().sendDMNotification(
|
||||
event.id,
|
||||
content,
|
||||
user,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: PrivateDmEvent) {
|
||||
private fun notify(event: PrivateDmEvent, acc: Account) {
|
||||
val note = LocalCache.notes[event.id] ?: return
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
||||
(
|
||||
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(it)
|
||||
) && !acc.isAllHidden(it.users)
|
||||
}.toSet()
|
||||
|
||||
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
||||
(
|
||||
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(it)
|
||||
) && !acc.isAllHidden(it.users)
|
||||
}.toSet()
|
||||
|
||||
note.author?.let {
|
||||
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
note.author?.let {
|
||||
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: LnZapEvent) {
|
||||
private fun notify(event: LnZapEvent, acc: Account) {
|
||||
val noteZapEvent = LocalCache.notes[event.id] ?: return
|
||||
|
||||
// old event being re-broadcast
|
||||
@@ -146,39 +222,35 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
|
||||
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
val amount = showAmount(event.amount)
|
||||
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
Pair(author, decryptedContent.content)
|
||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||
} else {
|
||||
Pair(noteZapRequest.author, null)
|
||||
}
|
||||
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
val amount = showAmount(event.amount)
|
||||
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
Pair(author, decryptedContent.content)
|
||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||
} else {
|
||||
Pair(noteZapRequest.author, null)
|
||||
}
|
||||
|
||||
val zappedContent =
|
||||
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
|
||||
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
|
||||
val zappedContent =
|
||||
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
|
||||
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ class RegisterAccounts(
|
||||
): List<RelayAuthEvent> {
|
||||
return accounts.mapNotNull {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if (acc != null) {
|
||||
if (acc != null && acc.isWriteable()) {
|
||||
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
|
||||
if (relayToUse != null) {
|
||||
acc.createAuthEvent(relayToUse, notificationToken)
|
||||
@@ -36,7 +36,7 @@ class RegisterAccounts(
|
||||
}
|
||||
}
|
||||
|
||||
private fun postRegistrationEvent(events: List<RelayAuthEvent>) {
|
||||
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
|
||||
try {
|
||||
val jsonObject = """{
|
||||
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
private const val ELEMENT_TAG_META = "meta"
|
||||
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
|
||||
@@ -64,7 +65,11 @@ suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder().url(url).get().build()
|
||||
val html = HttpClient.getHttpClient().newCall(request).execute().use {
|
||||
it.body.string()
|
||||
if (it.isSuccessful) {
|
||||
it.body.string()
|
||||
} else {
|
||||
throw IllegalArgumentException("Website returned: " + it.code)
|
||||
}
|
||||
}
|
||||
|
||||
Jsoup.parse(html)
|
||||
|
||||
@@ -16,9 +16,7 @@ object Constants {
|
||||
}
|
||||
|
||||
val defaultRelays = arrayOf(
|
||||
// Free relays for DMs and Follows
|
||||
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
|
||||
// Free relays for only DMs and Follows due to the amount of spam
|
||||
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
|
||||
|
||||
// Chats
|
||||
@@ -40,6 +38,7 @@ object Constants {
|
||||
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
|
||||
|
||||
// Paid relays
|
||||
RelaySetupInfo("wss://relay.snort.social", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
@@ -51,8 +50,15 @@ object Constants {
|
||||
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
)
|
||||
|
||||
val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
val forcedRelayForSearch = arrayOf(
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
)
|
||||
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
|
||||
@@ -12,6 +16,11 @@ object RelayPool : Relay.Listener {
|
||||
private var relays = listOf<Relay>()
|
||||
private var listeners = setOf<Listener>()
|
||||
|
||||
// Backing property to avoid flow emissions from other classes
|
||||
private var _lastStatus = RelayPoolStatus(0, 0)
|
||||
private val _statusFlow = MutableSharedFlow<RelayPoolStatus>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val statusFlow: SharedFlow<RelayPoolStatus> = _statusFlow.asSharedFlow()
|
||||
|
||||
fun availableRelays(): Int {
|
||||
return relays.size
|
||||
}
|
||||
@@ -76,11 +85,13 @@ object RelayPool : Relay.Listener {
|
||||
fun addRelay(relay: Relay) {
|
||||
relay.register(this)
|
||||
relays += relay
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
fun removeRelay(relay: Relay) {
|
||||
relay.unregister(this)
|
||||
relays = relays.minus(relay)
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
fun register(listener: Listener) {
|
||||
@@ -109,12 +120,14 @@ object RelayPool : Relay.Listener {
|
||||
|
||||
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
|
||||
listeners.forEach { it.onError(error, subscriptionId, relay) }
|
||||
refreshObservers()
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(relay: Relay, type: Relay.Type, channel: String?) {
|
||||
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
|
||||
refreshObservers()
|
||||
if (type != Relay.Type.EOSE) {
|
||||
updateStatus()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) {
|
||||
@@ -125,18 +138,15 @@ object RelayPool : Relay.Listener {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: RelayPoolLiveData = RelayPoolLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.refresh()
|
||||
private fun updateStatus() {
|
||||
val connected = connectedRelays()
|
||||
val available = availableRelays()
|
||||
if (_lastStatus.connected != connected || _lastStatus.available != available) {
|
||||
_lastStatus = RelayPoolStatus(connected, available)
|
||||
_statusFlow.tryEmit(_lastStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RelayPoolLiveData(val relays: RelayPool) : LiveData<RelayPoolState>(RelayPoolState(relays)) {
|
||||
fun refresh() {
|
||||
postValue(RelayPoolState(relays))
|
||||
}
|
||||
}
|
||||
|
||||
class RelayPoolState(val relays: RelayPool)
|
||||
@Immutable
|
||||
data class RelayPoolStatus(val connected: Int, val available: Int, val isConnected: Boolean = connected > 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
@@ -22,6 +23,7 @@ import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
|
||||
@@ -50,9 +52,10 @@ import java.nio.charset.StandardCharsets
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ExternalSignerUtils.start(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
LocalPreferences.migrateSingleUserPrefs()
|
||||
val language = LocalPreferences.getPreferredLanguage()
|
||||
if (language.isNotBlank()) {
|
||||
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
|
||||
@@ -65,7 +68,10 @@ class MainActivity : AppCompatActivity() {
|
||||
themeViewModel.onChange(LocalPreferences.getTheme())
|
||||
AmethystTheme(themeViewModel) {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background
|
||||
) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(this@MainActivity)
|
||||
}
|
||||
@@ -81,7 +87,8 @@ class MainActivity : AppCompatActivity() {
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.build()
|
||||
|
||||
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
val connectivityManager =
|
||||
getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
connectivityManager.requestNetwork(networkRequest, networkCallback)
|
||||
}
|
||||
|
||||
@@ -94,11 +101,13 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start(this@MainActivity)
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
ServiceManager.start(this@MainActivity)
|
||||
}
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
PushNotificationUtils.init(LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +117,9 @@ class MainActivity : AppCompatActivity() {
|
||||
debugState(this)
|
||||
// }
|
||||
|
||||
ServiceManager.pause()
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
ServiceManager.pause()
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
@@ -140,8 +151,10 @@ class MainActivity : AppCompatActivity() {
|
||||
Log.d("NETWORKCALLBACK", "onAvailable: Disconnecting and connecting again")
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.pause()
|
||||
ServiceManager.start(this@MainActivity)
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
ServiceManager.pause()
|
||||
ServiceManager.start(this@MainActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +166,8 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val hasMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val hasMobileData =
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
|
||||
@@ -170,7 +184,9 @@ class MainActivity : AppCompatActivity() {
|
||||
Log.d("NETWORKCALLBACK", "onLost: Disconnecting and pausing relay's connection")
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.pause()
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
ServiceManager.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,9 +194,15 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
return super.createIntent(context, input).apply {
|
||||
// Force only images and videos to be selectable
|
||||
// Force OPEN Document because of the resulting URI must be passed to the
|
||||
// Playback service and the picker's permissions only allow the activity to read the URI
|
||||
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
// Force only images and videos to be selectable
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,24 +92,32 @@ open class NewMediaModel : ViewModel() {
|
||||
} else {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Uploading"
|
||||
ImageUploader.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description, sensitiveContent, relayList = relayList)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
sensitiveContent,
|
||||
relayList = relayList
|
||||
)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -49,6 +50,7 @@ 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
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -79,13 +81,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
var showRelaysDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var relayList = account.activeRelays()?.filter {
|
||||
it.write
|
||||
}?.map {
|
||||
it
|
||||
} ?: account.convertLocalRelays().filter {
|
||||
it.write
|
||||
}
|
||||
var relayList = account.activeWriteRelays()
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
@@ -101,7 +97,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
) {
|
||||
if (showRelaysDialog) {
|
||||
RelaySelectionDialog(
|
||||
list = relayList,
|
||||
preSelectedList = relayList,
|
||||
onClose = {
|
||||
showRelaysDialog = false
|
||||
},
|
||||
@@ -191,8 +187,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
|
||||
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
Row(
|
||||
@@ -221,7 +216,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
postViewModel.imageUploadingError.emit("Unable to load file")
|
||||
postViewModel.imageUploadingError.emit("Unable to load thumbnail, but the video can be uploaded")
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,7 +252,6 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second,
|
||||
options = fileServerOptions,
|
||||
explainers = fileServerExplainers,
|
||||
onSelect = {
|
||||
postViewModel.selectedServer = fileServers[it].first
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
@@ -13,7 +13,8 @@ class NewMessageTagger(
|
||||
var message: String,
|
||||
var mentions: List<User>? = null,
|
||||
var replyTos: List<Note>? = null,
|
||||
var channelHex: String? = null
|
||||
var channelHex: String? = null,
|
||||
var dao: Dao
|
||||
) {
|
||||
|
||||
val directMentions = mutableSetOf<HexKey>()
|
||||
@@ -40,20 +41,20 @@ class NewMessageTagger(
|
||||
return (if (channelHex != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
}
|
||||
|
||||
fun run() {
|
||||
suspend fun run() {
|
||||
// adds all references to mentions and reply tos
|
||||
message.split('\n').forEach { paragraph: String ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
|
||||
addUserToMentions(dao.getOrCreateUser(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
addNoteToReplyTos(note)
|
||||
}
|
||||
@@ -66,19 +67,19 @@ class NewMessageTagger(
|
||||
paragraph.split(' ').map { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
val user = LocalCache.getOrCreateUser(results.key.hex)
|
||||
val user = dao.getOrCreateUser(results.key.hex)
|
||||
|
||||
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
val note = dao.getOrCreateNote(results.key.hex)
|
||||
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
val note = dao.getOrCreateNote(results.key.hex)
|
||||
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
"nostr:${note.idNote()}${results.restOfWord}"
|
||||
} else {
|
||||
@@ -91,6 +92,7 @@ class NewMessageTagger(
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
|
||||
|
||||
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
@@ -101,37 +103,50 @@ class NewMessageTagger(
|
||||
|
||||
key = key.removePrefix("@")
|
||||
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
if (key.startsWith("nsec1", true)) {
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
}
|
||||
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
// Converts to npub
|
||||
val pubkey = Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
}
|
||||
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
}
|
||||
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
val noteId = Nip19.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteId, restOfWord)
|
||||
} else if (key.startsWith("nprofile", true)) {
|
||||
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
val pubkeyRelay = Nip19.uriToRoute(key) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
|
||||
} else if (key.startsWith("nevent1", true)) {
|
||||
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
val noteRelayId = Nip19.uriToRoute(key) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
|
||||
} else if (key.startsWith("naddr1", true)) {
|
||||
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
val address = Nip19.uriToRoute(key) ?: return null
|
||||
|
||||
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
|
||||
}
|
||||
@@ -142,3 +157,9 @@ class NewMessageTagger(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface Dao {
|
||||
suspend fun getOrCreateUser(hex: String): User
|
||||
suspend fun getOrCreateNote(hex: String): Note
|
||||
suspend fun checkGetOrCreateAddressableNote(hex: String): Note?
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -82,20 +83,25 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.CancelIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.CloseIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.PollIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
@@ -111,6 +117,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.Math.round
|
||||
|
||||
@Composable
|
||||
fun NewPostView(
|
||||
@@ -133,16 +140,10 @@ fun NewPostView(
|
||||
var showRelaysDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var relayList = account.activeRelays()?.filter {
|
||||
it.write
|
||||
}?.map {
|
||||
it
|
||||
} ?: account.convertLocalRelays().filter {
|
||||
it.write
|
||||
}
|
||||
var relayList = account.activeWriteRelays()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account, baseReplyTo, quote)
|
||||
postViewModel.load(accountViewModel, baseReplyTo, quote)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
@@ -169,7 +170,7 @@ fun NewPostView(
|
||||
) {
|
||||
if (showRelaysDialog) {
|
||||
RelaySelectionDialog(
|
||||
list = relayList,
|
||||
preSelectedList = relayList,
|
||||
onClose = {
|
||||
showRelaysDialog = false
|
||||
},
|
||||
@@ -211,10 +212,8 @@ fun NewPostView(
|
||||
}
|
||||
PostButton(
|
||||
onPost = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendPost(relayList = relayList)
|
||||
onClose()
|
||||
}
|
||||
postViewModel.sendPost(relayList = relayList)
|
||||
onClose()
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
@@ -327,9 +326,9 @@ fun NewPostView(
|
||||
if (postViewModel.wantsForwardZapTo) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp)
|
||||
) {
|
||||
FowardZapTo(postViewModel)
|
||||
FowardZapTo(postViewModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,7 +726,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FowardZapTo(postViewModel: NewPostViewModel) {
|
||||
fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
@@ -761,7 +760,7 @@ fun FowardZapTo(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.zap_forward_title),
|
||||
text = stringResource(R.string.zap_split_title),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
@@ -771,22 +770,52 @@ fun FowardZapTo(postViewModel: NewPostViewModel) {
|
||||
Divider()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.zap_forward_explainer),
|
||||
text = stringResource(R.string.zap_split_explainer),
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
modifier = Modifier.padding(vertical = 10.dp)
|
||||
)
|
||||
|
||||
postViewModel.forwardZapTo.items.forEachIndexed { index, splitItem ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
|
||||
BaseUserPicture(splitItem.key, Size55dp, accountViewModel = accountViewModel)
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
UsernameDisplay(splitItem.key, showPlayButton = false)
|
||||
Text(
|
||||
text = String.format("%.0f%%", splitItem.percentage * 100),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Slider(
|
||||
value = splitItem.percentage,
|
||||
onValueChange = { sliderValue ->
|
||||
val rounded = (round(sliderValue * 20)) / 20.0f
|
||||
postViewModel.updateZapPercentage(index, rounded)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.forwardZapToEditting,
|
||||
onValueChange = {
|
||||
postViewModel.updateZapForwardTo(it)
|
||||
},
|
||||
|
||||
label = { Text(text = stringResource(R.string.zap_forward_lnAddress)) },
|
||||
label = { Text(text = stringResource(R.string.zap_split_serarch_and_add_user)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.zap_forward_lnAddress),
|
||||
text = stringResource(R.string.zap_split_serarch_and_add_user_placeholder),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
@@ -1290,8 +1319,7 @@ fun ImageVideoDescription(
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
|
||||
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
|
||||
var selectedServer by remember { mutableStateOf(defaultServer) }
|
||||
var message by remember { mutableStateOf("") }
|
||||
@@ -1376,8 +1404,8 @@ fun ImageVideoDescription(
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
onError("Unable to load file")
|
||||
Log.e("NewPostView", "Couldn't create thumbnail for $uri")
|
||||
onError("Unable to load thumbnail")
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1405,7 +1433,6 @@ fun ImageVideoDescription(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
|
||||
options = fileServerOptions,
|
||||
explainers = fileServerExplainers,
|
||||
onSelect = {
|
||||
selectedServer = fileServers[it].first
|
||||
},
|
||||
|
||||
@@ -21,7 +21,9 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.Split
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
@@ -29,6 +31,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -43,6 +46,7 @@ enum class UserSuggestionAnchor {
|
||||
|
||||
@Stable
|
||||
open class NewPostViewModel() : ViewModel() {
|
||||
var accountViewModel: AccountViewModel? = null
|
||||
var account: Account? = null
|
||||
var requiresNIP24: Boolean = false
|
||||
|
||||
@@ -88,9 +92,13 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
|
||||
data class ForwardZapSetup(val user: User) {
|
||||
var percentage by mutableStateOf(100)
|
||||
}
|
||||
|
||||
// Forward Zap to
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
var forwardZapTo by mutableStateOf<User?>(null)
|
||||
var forwardZapTo by mutableStateOf<Split<User>>(Split())
|
||||
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
// NSFW, Sensitive
|
||||
@@ -109,7 +117,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
// NIP24 Wrapped DMs / Group messages
|
||||
var nip24 by mutableStateOf(false)
|
||||
|
||||
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
open fun load(accountViewModel: AccountViewModel, replyingTo: Note?, quote: Note?) {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
if (replyNote.event is BaseTextNoteEvent) {
|
||||
@@ -141,8 +149,8 @@ open class NewPostViewModel() : ViewModel() {
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
canAddInvoice = account.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = account.userProfile().info?.lnAddress() != null
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
@@ -151,25 +159,40 @@ open class NewPostViewModel() : ViewModel() {
|
||||
wantsToAddGeoHash = false
|
||||
wantsZapraiser = false
|
||||
zapRaiserAmount = null
|
||||
forwardZapTo = null
|
||||
forwardZapTo = Split()
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
this.account = account
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<Relay>? = null) {
|
||||
val tagger = NewMessageTagger(message.text, mentions, replyTos, originalNote?.channelHex())
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
innerSendPost(relayList)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun innerSendPost(relayList: List<Relay>? = null) {
|
||||
if (accountViewModel == null) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
val tagger = NewMessageTagger(message.text, mentions, replyTos, originalNote?.channelHex(), accountViewModel!!)
|
||||
tagger.run()
|
||||
|
||||
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null)
|
||||
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!)
|
||||
toUsersTagger.run()
|
||||
val dmUsers = toUsersTagger.mentions
|
||||
|
||||
val zapReceiver = if (wantsForwardZapTo) {
|
||||
if (forwardZapTo != null) {
|
||||
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
|
||||
} else {
|
||||
forwardZapToEditting.text
|
||||
forwardZapTo?.items?.map {
|
||||
ZapSplitSetup(
|
||||
lnAddressOrPubKeyHex = it.key.pubkeyHex,
|
||||
relay = it.key.relaysBeingUsed.keys.firstOrNull(),
|
||||
weight = it.percentage.toDouble(),
|
||||
isLnAddress = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
@@ -355,7 +378,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
wantsToAddGeoHash = false
|
||||
forwardZapTo = null
|
||||
forwardZapTo = Split()
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
userSuggestions = emptyList()
|
||||
@@ -427,10 +450,10 @@ open class NewPostViewModel() : ViewModel() {
|
||||
open fun updateZapForwardTo(it: TextFieldValue) {
|
||||
forwardZapToEditting = it
|
||||
if (it.selection.collapsed) {
|
||||
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWord = it.text
|
||||
userSuggestionAnchor = it.selection
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
if (lastWord.length > 2) {
|
||||
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
@@ -460,6 +483,9 @@ open class NewPostViewModel() : ViewModel() {
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo?.addItem(item)
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
/*
|
||||
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
@@ -468,7 +494,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
forwardZapToEditting = TextFieldValue(
|
||||
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)
|
||||
)*/
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
|
||||
val lastWord = toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
@@ -656,6 +682,10 @@ open class NewPostViewModel() : ViewModel() {
|
||||
isValidvalueMaximum.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun updateZapPercentage(index: Int, sliderValue: Float) {
|
||||
forwardZapTo?.updatePercentage(index, sliderValue)
|
||||
}
|
||||
}
|
||||
|
||||
enum class GeohashPrecision(val digits: Int) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
@@ -24,6 +25,7 @@ class NewRelayListViewModel : ViewModel() {
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
clear()
|
||||
loadRelayDocuments()
|
||||
}
|
||||
|
||||
fun create() {
|
||||
@@ -36,17 +38,34 @@ class NewRelayListViewModel : ViewModel() {
|
||||
clear()
|
||||
}
|
||||
|
||||
fun loadRelayDocuments() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_relays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
onInfo = {
|
||||
togglePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_relays.update {
|
||||
var relayFile = account.userProfile().latestContactList?.relays()
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
// TODO: Remove when search becomes more available.
|
||||
if (relayFile?.none { it.key == Constants.forcedRelayForSearch.url } == true) {
|
||||
relayFile = relayFile + Pair(
|
||||
Constants.forcedRelayForSearch.url,
|
||||
ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write)
|
||||
)
|
||||
if (relayFile?.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } == true) {
|
||||
relayFile = relayFile + Constants.forcedRelayForSearch.map {
|
||||
Pair(
|
||||
it.url,
|
||||
ContactListEvent.ReadWrite(it.read, it.write)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (relayFile != null) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -37,6 +38,7 @@ import kotlinx.coroutines.withContext
|
||||
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
val postViewModel: NewUserMetadataViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
@@ -82,7 +84,9 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState())
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(1f),
|
||||
|
||||
+18
-17
@@ -124,7 +124,6 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
|
||||
}
|
||||
|
||||
clear()
|
||||
}
|
||||
|
||||
@@ -183,23 +182,25 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
contentResolver.getType(galleryUri),
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
ImageUploader.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = account.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
onUploading(false)
|
||||
onUploaded(imageUrl)
|
||||
},
|
||||
onError = {
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = account.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
onUploading(false)
|
||||
onUploaded(imageUrl)
|
||||
},
|
||||
onError = {
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
onUploading(false)
|
||||
|
||||
+34
-110
@@ -1,8 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -28,27 +25,24 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RelayInformationDialog(
|
||||
onClose: () -> Unit,
|
||||
relayBriefInfo: RelayBriefInfo,
|
||||
relayInfo: RelayInformation,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
@@ -79,18 +73,25 @@ fun RelayInformationDialog(
|
||||
})
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Title(relayInfo.name ?: "")
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = StdPadding.fillMaxWidth()) {
|
||||
Column() {
|
||||
RenderRelayIcon(
|
||||
relayBriefInfo.favIcon,
|
||||
Size55dp
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
SectionContent(relayInfo.description ?: "")
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row() {
|
||||
Title(relayInfo.name?.trim() ?: "")
|
||||
}
|
||||
|
||||
Row() {
|
||||
SubtitleContent(relayInfo.description?.trim() ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(stringResource(R.string.owner))
|
||||
@@ -212,7 +213,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
|
||||
val text = item.toString().padStart(2, '0')
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
ClickableUrl(
|
||||
urlText = "$text",
|
||||
urlText = text,
|
||||
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
|
||||
)
|
||||
}
|
||||
@@ -222,7 +223,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
|
||||
val text = item.padStart(2, '0')
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
ClickableUrl(
|
||||
urlText = "$text",
|
||||
urlText = text,
|
||||
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
|
||||
)
|
||||
}
|
||||
@@ -247,7 +248,7 @@ private fun DisplayOwnerInformation(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadUser(baseUserHex = userHex) {
|
||||
LoadUser(baseUserHex = userHex, accountViewModel) {
|
||||
Crossfade(it) {
|
||||
if (it != null) {
|
||||
UserCompose(baseUser = it, accountViewModel = accountViewModel, showDiviser = false, nav = nav)
|
||||
@@ -258,13 +259,18 @@ private fun DisplayOwnerInformation(
|
||||
|
||||
@Composable
|
||||
fun Title(text: String) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(
|
||||
text = text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp
|
||||
)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SubtitleContent(text: String) {
|
||||
Text(
|
||||
text = text
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -285,85 +291,3 @@ fun SectionContent(text: String) {
|
||||
text = text
|
||||
)
|
||||
}
|
||||
|
||||
fun loadRelayInfo(
|
||||
dirtyUrl: String,
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
onInfo: (RelayInformation) -> Unit
|
||||
) {
|
||||
try {
|
||||
val url = if (dirtyUrl.contains("://")) {
|
||||
dirtyUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
} else {
|
||||
"https://$dirtyUrl"
|
||||
}
|
||||
|
||||
val request: Request = Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(RelayInformation.fromJson(body))
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +29,27 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class RelayList(
|
||||
val relay: Relay,
|
||||
val relayInfo: RelayBriefInfo,
|
||||
val isSelected: Boolean
|
||||
)
|
||||
|
||||
data class RelayInfoDialog(
|
||||
val relayBriefInfo: RelayBriefInfo,
|
||||
val relayInfo: RelayInformation
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RelaySelectionDialog(
|
||||
list: List<Relay>,
|
||||
preSelectedList: List<Relay>,
|
||||
onClose: () -> Unit,
|
||||
onPost: (list: List<Relay>) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -49,34 +57,29 @@ fun RelaySelectionDialog(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val relayList = accountViewModel.account.activeRelays()?.filter {
|
||||
it.write
|
||||
}?.map {
|
||||
it
|
||||
} ?: accountViewModel.account.convertLocalRelays().filter {
|
||||
it.write
|
||||
}
|
||||
|
||||
var relays by remember {
|
||||
mutableStateOf(
|
||||
relayList.map {
|
||||
accountViewModel.account.activeWriteRelays().map {
|
||||
RelayList(
|
||||
it,
|
||||
list.any { relay -> it.url == relay.url }
|
||||
relay = it,
|
||||
relayInfo = RelayBriefInfo(it.url),
|
||||
isSelected = preSelectedList.any { relay -> it.url == relay.url }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
|
||||
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
|
||||
|
||||
if (relayInfo != null) {
|
||||
relayInfo?.let {
|
||||
RelayInformationDialog(
|
||||
onClose = {
|
||||
relayInfo = null
|
||||
},
|
||||
relayInfo = relayInfo!!,
|
||||
accountViewModel,
|
||||
nav
|
||||
relayInfo = it.relayInfo,
|
||||
relayBriefInfo = it.relayBriefInfo,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,14 +118,14 @@ fun RelaySelectionDialog(
|
||||
}
|
||||
)
|
||||
|
||||
PostButton(
|
||||
SaveButton(
|
||||
onPost = {
|
||||
val selectedRelays = relays.filter { it.isSelected }
|
||||
if (selectedRelays.isEmpty()) {
|
||||
scope.launch {
|
||||
Toast.makeText(context, context.getString(R.string.select_a_relay_to_continue), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
return@PostButton
|
||||
return@SaveButton
|
||||
}
|
||||
onPost(selectedRelays.map { it.relay })
|
||||
onClose()
|
||||
@@ -153,10 +156,7 @@ fun RelaySelectionDialog(
|
||||
key = { _, item -> item.relay.url }
|
||||
) { index, item ->
|
||||
RelaySwitch(
|
||||
text = item.relay.url
|
||||
.removePrefix("ws://")
|
||||
.removePrefix("wss://")
|
||||
.removeSuffix("/"),
|
||||
text = item.relayInfo.displayUrl,
|
||||
checked = item.isSelected,
|
||||
onClick = {
|
||||
relays = relays.mapIndexed { j, item ->
|
||||
@@ -168,9 +168,30 @@ fun RelaySelectionDialog(
|
||||
}
|
||||
},
|
||||
onLongPress = {
|
||||
loadRelayInfo(item.relay.url, context, scope) {
|
||||
relayInfo = it
|
||||
}
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
item.relay.url,
|
||||
onInfo = {
|
||||
relayInfo = RelayInfoDialog(RelayBriefInfo(item.relay.url), it)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
val msg = when (errorCode) {
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
msg,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewCommunityNoteButton(communityIdHex: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
LoadNote(baseNoteHex = communityIdHex) {
|
||||
LoadNote(baseNoteHex = communityIdHex, accountViewModel) {
|
||||
it?.let {
|
||||
NewCommunityNoteButton(it, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@ import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NIP30Parser
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
@@ -61,20 +61,21 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun ClickableRoute(
|
||||
nip19: Nip19.Return,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
when (nip19.type) {
|
||||
Nip19.Type.USER -> {
|
||||
DisplayUser(nip19, nav)
|
||||
DisplayUser(nip19, accountViewModel, nav)
|
||||
}
|
||||
Nip19.Type.ADDRESS -> {
|
||||
DisplayAddress(nip19, nav)
|
||||
DisplayAddress(nip19, accountViewModel, nav)
|
||||
}
|
||||
Nip19.Type.NOTE -> {
|
||||
DisplayNote(nip19, nav)
|
||||
DisplayNote(nip19, accountViewModel, nav)
|
||||
}
|
||||
Nip19.Type.EVENT -> {
|
||||
DisplayEvent(nip19, nav)
|
||||
DisplayEvent(nip19, accountViewModel, nav)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
@@ -86,34 +87,15 @@ fun ClickableRoute(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadNote(
|
||||
hex: String,
|
||||
content: @Composable (Note) -> Unit
|
||||
) {
|
||||
var noteBase by remember(hex) { mutableStateOf(LocalCache.getNoteIfExists(hex)) }
|
||||
|
||||
if (noteBase == null) {
|
||||
LaunchedEffect(key1 = hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(hex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
content(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayEvent(
|
||||
nip19: Nip19.Return,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadNote(nip19.hex) {
|
||||
LoadNote(nip19.hex, accountViewModel) {
|
||||
if (it != null) {
|
||||
DisplayNoteLink(it, nip19, nav)
|
||||
DisplayNoteLink(it, nip19, accountViewModel, nav)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
|
||||
@@ -128,11 +110,12 @@ private fun DisplayEvent(
|
||||
@Composable
|
||||
private fun DisplayNote(
|
||||
nip19: Nip19.Return,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadNote(nip19.hex) {
|
||||
LoadNote(nip19.hex, accountViewModel = accountViewModel) {
|
||||
if (it != null) {
|
||||
DisplayNoteLink(it, nip19, nav)
|
||||
DisplayNoteLink(it, nip19, accountViewModel, nav)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
|
||||
@@ -148,6 +131,7 @@ private fun DisplayNote(
|
||||
private fun DisplayNoteLink(
|
||||
it: Note,
|
||||
nip19: Nip19.Return,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
@@ -177,7 +161,7 @@ private fun DisplayNoteLink(
|
||||
nav = nav
|
||||
)
|
||||
} else if (channelHex != null) {
|
||||
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
|
||||
LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel ->
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channelDisplayName by remember(channelState) {
|
||||
derivedStateOf {
|
||||
@@ -205,14 +189,15 @@ private fun DisplayNoteLink(
|
||||
@Composable
|
||||
private fun DisplayAddress(
|
||||
nip19: Nip19.Return,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var noteBase by remember(nip19) { mutableStateOf(LocalCache.getNoteIfExists(nip19.hex)) }
|
||||
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) }
|
||||
|
||||
if (noteBase == null) {
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) {
|
||||
noteBase = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,14 +229,19 @@ private fun DisplayAddress(
|
||||
@Composable
|
||||
private fun DisplayUser(
|
||||
nip19: Nip19.Return,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var userBase by remember(nip19) { mutableStateOf(LocalCache.getUserIfExists(nip19.hex)) }
|
||||
var userBase by remember(nip19) {
|
||||
mutableStateOf(
|
||||
accountViewModel.getUserIfExists(nip19.hex)
|
||||
)
|
||||
}
|
||||
|
||||
if (userBase == null) {
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
|
||||
accountViewModel.checkGetOrCreateUser(nip19.hex) {
|
||||
userBase = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ private fun RenderWordWithoutPreview(
|
||||
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||
is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav)
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
is HashIndexUserSegment -> TagLink(word, nav)
|
||||
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
|
||||
is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||
is RegularTextSegment -> NormalWord(word.segmentText, style)
|
||||
@@ -270,7 +270,7 @@ private fun RenderWordWithPreview(
|
||||
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||
is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
is HashIndexUserSegment -> TagLink(word, nav)
|
||||
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
|
||||
is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||
is RegularTextSegment -> NormalWord(word.segmentText, style)
|
||||
@@ -654,7 +654,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
|
||||
}
|
||||
} else if (loadedLink?.nip19 != null) {
|
||||
Row() {
|
||||
ClickableRoute(loadedLink?.nip19!!, nav)
|
||||
ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav)
|
||||
}
|
||||
} else {
|
||||
val text = remember {
|
||||
@@ -763,8 +763,8 @@ private fun InlineIcon(hashtagIcon: HashtagIcon) =
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagLink(word: HashIndexUserSegment, nav: (String) -> Unit) {
|
||||
LoadUser(baseUserHex = word.hex) {
|
||||
fun TagLink(word: HashIndexUserSegment, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
LoadUser(baseUserHex = word.hex, accountViewModel) {
|
||||
if (it == null) {
|
||||
Text(text = word.segmentText)
|
||||
} else {
|
||||
@@ -776,15 +776,15 @@ fun TagLink(word: HashIndexUserSegment, nav: (String) -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadNote(baseNoteHex: String, content: @Composable (Note?) -> Unit) {
|
||||
fun LoadNote(baseNoteHex: String, accountViewModel: AccountViewModel, content: @Composable (Note?) -> Unit) {
|
||||
var note by remember(baseNoteHex) {
|
||||
mutableStateOf<Note?>(LocalCache.getNoteIfExists(baseNoteHex))
|
||||
mutableStateOf<Note?>(accountViewModel.getNoteIfExists(baseNoteHex))
|
||||
}
|
||||
|
||||
if (note == null) {
|
||||
LaunchedEffect(key1 = baseNoteHex) {
|
||||
launch(Dispatchers.IO) {
|
||||
note = LocalCache.checkGetOrCreateNote(baseNoteHex)
|
||||
accountViewModel.checkGetOrCreateNote(baseNoteHex) {
|
||||
note = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -794,7 +794,7 @@ fun LoadNote(baseNoteHex: String, content: @Composable (Note?) -> Unit) {
|
||||
|
||||
@Composable
|
||||
fun TagLink(word: HashIndexEventSegment, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
LoadNote(baseNoteHex = word.hex) {
|
||||
LoadNote(baseNoteHex = word.hex, accountViewModel) {
|
||||
if (it == null) {
|
||||
Text(text = remember { word.segmentText.toShortenHex() })
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlin.math.abs
|
||||
|
||||
class SplitItem<T>(val key: T) {
|
||||
var percentage by mutableStateOf(0f)
|
||||
}
|
||||
|
||||
class Split<T>() {
|
||||
var items: List<SplitItem<T>> by mutableStateOf(emptyList())
|
||||
|
||||
fun addItem(key: T): Int {
|
||||
val wasEqualSplit = isEqualSplit()
|
||||
val newItem = SplitItem(key)
|
||||
items = items.plus(newItem)
|
||||
|
||||
if (wasEqualSplit) {
|
||||
forceEqualSplit()
|
||||
} else {
|
||||
updatePercentage(items.lastIndex, equalSplit())
|
||||
}
|
||||
|
||||
return items.lastIndex
|
||||
}
|
||||
|
||||
fun equalSplit() = 1f / items.size
|
||||
|
||||
fun isEqualSplit(): Boolean {
|
||||
val expectedPercentage = equalSplit()
|
||||
return items.all { (it.percentage - expectedPercentage) < 0.01 }
|
||||
}
|
||||
|
||||
fun forceEqualSplit() {
|
||||
val correctPercentage = equalSplit()
|
||||
items.forEach {
|
||||
it.percentage = correctPercentage
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePercentage(index: Int, percentage: Float) {
|
||||
if (items.isEmpty()) return
|
||||
|
||||
val splitItem = items.getOrNull(index) ?: return
|
||||
|
||||
if (items.size == 1) {
|
||||
splitItem.percentage = 1f
|
||||
} else {
|
||||
splitItem.percentage = percentage
|
||||
|
||||
println("Update ${items[index].key} to $percentage")
|
||||
|
||||
val othersMustShare = 1.0f - splitItem.percentage
|
||||
|
||||
val othersHave = items.sumOf {
|
||||
if (it == splitItem) 0.0 else it.percentage.toDouble()
|
||||
}.toFloat()
|
||||
|
||||
if (abs(othersHave - othersMustShare) < 0.01) return // nothing to do
|
||||
|
||||
println("Others Must Share $othersMustShare but have $othersHave")
|
||||
|
||||
bottomUpAdjustment(othersMustShare, othersHave, index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bottomUpAdjustment(othersMustShare: Float, othersHave: Float, exceptForIndex: Int) {
|
||||
var needToRemove = othersHave - othersMustShare
|
||||
if (needToRemove > 0) {
|
||||
for (i in items.indices.reversed()) {
|
||||
if (i == exceptForIndex) continue // do not update the current item
|
||||
|
||||
if (needToRemove < items[i].percentage) {
|
||||
val oldValue = items[i].percentage
|
||||
items[i].percentage -= needToRemove
|
||||
needToRemove = 0f
|
||||
println("- Updating ${items[i].key} from $oldValue to ${items[i].percentage - needToRemove}. $needToRemove left")
|
||||
} else {
|
||||
val oldValue = items[i].percentage
|
||||
needToRemove -= items[i].percentage
|
||||
items[i].percentage = 0f
|
||||
println("- Updating ${items[i].key} from $oldValue to ${0}. $needToRemove left")
|
||||
}
|
||||
|
||||
if (needToRemove < 0.01) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (needToRemove < 0) {
|
||||
if (items.lastIndex == exceptForIndex) {
|
||||
items[items.lastIndex - 1].percentage += -needToRemove
|
||||
} else {
|
||||
items.last().percentage += -needToRemove
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -37,8 +38,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
fun TextSpinner(
|
||||
label: String?,
|
||||
placeholder: String,
|
||||
options: ImmutableList<String>,
|
||||
explainers: ImmutableList<String>? = null,
|
||||
options: ImmutableList<TitleExplainer>,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -74,8 +74,8 @@ fun TextSpinner(
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it]
|
||||
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it].title
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
}
|
||||
@@ -85,10 +85,40 @@ fun TextSpinner(
|
||||
|
||||
@Composable
|
||||
fun SpinnerSelectionDialog(
|
||||
options: ImmutableList<String>,
|
||||
explainers: ImmutableList<String>?,
|
||||
options: ImmutableList<TitleExplainer>,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (Int) -> Unit
|
||||
) {
|
||||
SpinnerSelectionDialog(
|
||||
options = options,
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss
|
||||
) { item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = item.title, color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
item.explainer?.let {
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it, color = Color.Gray, fontSize = Font14SP)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> SpinnerSelectionDialog(
|
||||
options: ImmutableList<T>,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onRenderItem: @Composable (T) -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
@@ -106,23 +136,7 @@ fun SpinnerSelectionDialog(
|
||||
}
|
||||
) {
|
||||
Column() {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = item, color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
explainers?.getOrNull(index)?.let {
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it, color = Color.Gray, fontSize = Font14SP)
|
||||
}
|
||||
}
|
||||
onRenderItem(item)
|
||||
}
|
||||
}
|
||||
if (index < options.lastIndex) {
|
||||
@@ -133,3 +147,6 @@ fun SpinnerSelectionDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class TitleExplainer(val title: String, val explainer: String? = null)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class TranslationConfig(
|
||||
val result: String?,
|
||||
val sourceLang: String?,
|
||||
val targetLang: String?,
|
||||
val showOriginal: Boolean
|
||||
)
|
||||
@@ -62,7 +62,7 @@ fun UrlPreviewCard(
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
.padding(start = 10.dp, end = 10.dp, top = 5.dp),
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -612,7 +613,7 @@ fun Waveform(
|
||||
DrawWaveform(waveform, waveformProgress, modifier)
|
||||
|
||||
val restartFlow = remember {
|
||||
mutableStateOf(0)
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
// Keeps the screen on while playing and viewing videos.
|
||||
|
||||
@@ -477,14 +477,16 @@ private fun AddedImageFeatures(
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
FlowRow() {
|
||||
FlowRow(Modifier.fillMaxWidth()) {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
FlowRow(Modifier.fillMaxWidth()) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
}
|
||||
}
|
||||
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
|
||||
object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
@@ -12,18 +14,44 @@ object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val privKey = account.keyPair.privKey ?: return emptyList()
|
||||
|
||||
val bookmarks = account.userProfile().latestBookmarkList
|
||||
|
||||
val notes = bookmarks?.privateTaggedEvents(privKey)
|
||||
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
|
||||
if (account.loginWithExternalSigner) {
|
||||
val id = bookmarks?.id
|
||||
if (id != null) {
|
||||
val decryptedContent = ExternalSignerUtils.cachedDecryptedContent[id]
|
||||
if (decryptedContent == null) {
|
||||
ExternalSignerUtils.decryptBookmark(
|
||||
bookmarks.content,
|
||||
account.keyPair.pubKey.toHexKey(),
|
||||
id
|
||||
)
|
||||
} else {
|
||||
bookmarks.decryptedContent = decryptedContent
|
||||
}
|
||||
}
|
||||
val decryptedContent = ExternalSignerUtils.cachedDecryptedContent[id] ?: ""
|
||||
|
||||
val addresses = bookmarks?.privateTaggedAddresses(privKey)
|
||||
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
|
||||
val notes = bookmarks?.privateTaggedEvents(decryptedContent)
|
||||
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
|
||||
val addresses = bookmarks?.privateTaggedAddresses(decryptedContent)
|
||||
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
|
||||
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
} else {
|
||||
val privKey = account.keyPair.privKey ?: return emptyList()
|
||||
|
||||
val notes = bookmarks?.privateTaggedEvents(privKey)
|
||||
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
|
||||
|
||||
val addresses = bookmarks?.privateTaggedAddresses(privKey)
|
||||
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
|
||||
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,18 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return account.getBlockList()
|
||||
val blockList = account.getBlockList()
|
||||
val decryptedContent = blockList?.decryptedContent ?: ""
|
||||
if (account.loginWithExternalSigner) {
|
||||
if (decryptedContent.isEmpty()) return emptyList()
|
||||
|
||||
return blockList
|
||||
?.publicAndPrivateUsers(decryptedContent)
|
||||
?.map { LocalCache.getOrCreateUser(it) }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
return blockList
|
||||
?.publicAndPrivateUsers(account.keyPair.privKey)
|
||||
?.map { LocalCache.getOrCreateUser(it) }
|
||||
?: emptyList()
|
||||
|
||||
@@ -18,11 +18,12 @@ class ThreadFeedFilter(val account: Account, val noteId: String) : FeedFilter<No
|
||||
val cachedSignatures: MutableMap<Note, Note.LevelSignature> = mutableMapOf()
|
||||
val followingSet = account.selectedUsersFollowList(KIND3_FOLLOWS) ?: emptySet()
|
||||
val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
|
||||
val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Currently orders by date of each event, descending, at each level of the reply stack
|
||||
val order = compareByDescending<Note> {
|
||||
it.replyLevelSignature(eventsToWatch, cachedSignatures, account.userProfile(), followingSet, now).signature
|
||||
it.replyLevelSignature(eventsInHex, cachedSignatures, account.userProfile(), followingSet, now).signature
|
||||
}
|
||||
|
||||
return eventsToWatch.sortedWith(order)
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -54,11 +55,13 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import coil.Coil
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
@@ -88,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.LongCommunityHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
|
||||
@@ -117,6 +121,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -227,7 +232,7 @@ private fun CommunityTopBar(
|
||||
nav: (String) -> Unit,
|
||||
navPopBack: () -> Unit
|
||||
) {
|
||||
LoadAddressableNote(aTagHex = id) { baseNote ->
|
||||
LoadAddressableNote(aTagHex = id, accountViewModel) { baseNote ->
|
||||
if (baseNote != null) {
|
||||
FlexibleTopBarWithBackButton(
|
||||
title = {
|
||||
@@ -288,7 +293,7 @@ private fun RenderRoomTopBar(
|
||||
if (room.users.size == 1) {
|
||||
FlexibleTopBarWithBackButton(
|
||||
title = {
|
||||
LoadUser(baseUserHex = room.users.first()) { baseUser ->
|
||||
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
|
||||
if (baseUser != null) {
|
||||
ClickableUserPicture(
|
||||
baseUser = baseUser,
|
||||
@@ -303,7 +308,7 @@ private fun RenderRoomTopBar(
|
||||
}
|
||||
},
|
||||
extendableRow = {
|
||||
LoadUser(baseUserHex = room.users.first()) {
|
||||
LoadUser(baseUserHex = room.users.first(), accountViewModel) {
|
||||
if (it != null) {
|
||||
UserCompose(
|
||||
baseUser = it,
|
||||
@@ -348,7 +353,7 @@ private fun ChannelTopBar(
|
||||
nav: (String) -> Unit,
|
||||
navPopBack: () -> Unit
|
||||
) {
|
||||
LoadChannel(baseChannelHex = id) { baseChannel ->
|
||||
LoadChannel(baseChannelHex = id, accountViewModel) { baseChannel ->
|
||||
FlexibleTopBarWithBackButton(
|
||||
prefixRow = {
|
||||
if (baseChannel is LiveActivitiesChannel) {
|
||||
@@ -382,11 +387,12 @@ fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState
|
||||
val list by accountViewModel.storiesListLiveData.observeAsState(GLOBAL_FOLLOWS)
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
list,
|
||||
true
|
||||
followListsModel = followLists,
|
||||
listName = list,
|
||||
withGlobal = true,
|
||||
withRoutes = false
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultStoriesFollowList(listName)
|
||||
accountViewModel.account.changeDefaultStoriesFollowList(listName.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,11 +403,16 @@ fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, a
|
||||
val list by accountViewModel.homeListLiveData.observeAsState(KIND3_FOLLOWS)
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
list,
|
||||
true
|
||||
followListsModel = followLists,
|
||||
listName = list,
|
||||
withGlobal = true,
|
||||
withRoutes = true
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultHomeFollowList(listName)
|
||||
if (listName.type == CodeNameType.ROUTE) {
|
||||
nav(listName.code)
|
||||
} else {
|
||||
accountViewModel.account.changeDefaultHomeFollowList(listName.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,11 +423,12 @@ fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: Scaffold
|
||||
val list by accountViewModel.notificationListLiveData.observeAsState(GLOBAL_FOLLOWS)
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
list,
|
||||
true
|
||||
followListsModel = followLists,
|
||||
listName = list,
|
||||
withGlobal = true,
|
||||
withRoutes = false
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultNotificationFollowList(listName)
|
||||
accountViewModel.account.changeDefaultNotificationFollowList(listName.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,11 +439,12 @@ fun DiscoveryTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldSta
|
||||
val list by accountViewModel.discoveryListLiveData.observeAsState(GLOBAL_FOLLOWS)
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
list,
|
||||
true
|
||||
followListsModel = followLists,
|
||||
listName = list,
|
||||
withGlobal = true,
|
||||
withRoutes = false
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultDiscoveryFollowList(listName)
|
||||
accountViewModel.account.changeDefaultDiscoveryFollowList(listName.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,8 +464,6 @@ fun GenericMainTopBar(
|
||||
nav: (String) -> Unit,
|
||||
content: @Composable (AccountViewModel) -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = BottomTopHeight) {
|
||||
MyTopAppBar(
|
||||
elevation = 0.dp,
|
||||
@@ -476,6 +487,7 @@ fun GenericMainTopBar(
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LoggedInUserPictureDrawer(accountViewModel) {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.open()
|
||||
@@ -506,10 +518,9 @@ private fun LoggedInUserPictureDrawer(
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val accountUserState by accountViewModel.account.userProfile().live().metadata.observeAsState()
|
||||
val profilePicture by accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState()
|
||||
|
||||
val pubkeyHex = remember { accountUserState?.user?.pubkeyHex ?: "" }
|
||||
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
|
||||
val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex }
|
||||
|
||||
IconButton(
|
||||
onClick = onClick
|
||||
@@ -525,37 +536,81 @@ private fun LoggedInUserPictureDrawer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowList(followListsModel: FollowListViewModel, listName: String, withGlobal: Boolean, onChange: (String) -> Unit) {
|
||||
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
|
||||
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
|
||||
fun FollowList(
|
||||
followListsModel: FollowListViewModel,
|
||||
listName: String,
|
||||
withGlobal: Boolean,
|
||||
withRoutes: Boolean,
|
||||
onChange: (CodeName) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val kind3Follow = CodeName(KIND3_FOLLOWS, ResourceName(R.string.follow_list_kind3follows, context), CodeNameType.HARDCODED)
|
||||
val globalFollow = CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global, context), CodeNameType.HARDCODED)
|
||||
|
||||
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
|
||||
|
||||
val followLists by followListsModel.followLists.collectAsState()
|
||||
val followLists by followListsModel.peopleLists.collectAsState()
|
||||
val routeList by followListsModel.routes.collectAsState()
|
||||
|
||||
val allLists = remember(followLists) {
|
||||
(defaultOptions + followLists)
|
||||
if (withRoutes) {
|
||||
(defaultOptions + followLists + routeList)
|
||||
} else {
|
||||
(defaultOptions + followLists)
|
||||
}
|
||||
}
|
||||
|
||||
val followNames by remember(followLists) {
|
||||
derivedStateOf {
|
||||
allLists.map { it.second }.toImmutableList()
|
||||
allLists.map { it.name }.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
SimpleTextSpinner(
|
||||
placeholder = allLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
|
||||
placeholder = allLists.firstOrNull { it.code == listName }?.name?.name() ?: "Select an Option",
|
||||
options = followNames,
|
||||
onSelect = {
|
||||
onChange(allLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
|
||||
onChange(allLists.getOrNull(it) ?: kind3Follow)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
enum class CodeNameType {
|
||||
HARDCODED, PEOPLE_LIST, ROUTE
|
||||
}
|
||||
|
||||
abstract class Name {
|
||||
abstract fun name(): String
|
||||
}
|
||||
|
||||
class GeoHashName(val geoHashTag: String) : Name() {
|
||||
override fun name() = "/g/$geoHashTag"
|
||||
}
|
||||
class HashtagName(val hashTag: String) : Name() {
|
||||
override fun name() = "#$hashTag"
|
||||
}
|
||||
class ResourceName(val resourceId: Int, val context: Context) : Name() {
|
||||
override fun name() = context.getString(resourceId)
|
||||
}
|
||||
|
||||
class PeopleListName(val note: AddressableNote) : Name() {
|
||||
override fun name() = note.dTag() ?: ""
|
||||
}
|
||||
class CommunityName(val note: AddressableNote) : Name() {
|
||||
override fun name() = "/n/${(note.dTag() ?: "")}"
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class CodeName(val code: String, val name: Name, val type: CodeNameType)
|
||||
|
||||
@Stable
|
||||
class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
private var _followLists = MutableStateFlow<ImmutableList<Pair<String, String>>>(emptyList<Pair<String, String>>().toPersistentList())
|
||||
val followLists = _followLists.asStateFlow()
|
||||
private var _peopleLists = MutableStateFlow<ImmutableList<CodeName>>(emptyList<CodeName>().toPersistentList())
|
||||
val peopleLists = _peopleLists.asStateFlow()
|
||||
|
||||
private var _routes = MutableStateFlow<ImmutableList<CodeName>>(emptyList<CodeName>().toPersistentList())
|
||||
val routes = _routes.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
@@ -573,14 +628,34 @@ class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
event.pubKey == account.userProfile().pubkeyHex &&
|
||||
(event.tags.size > 1 || event.content.length > 50)
|
||||
) {
|
||||
Pair(event.dTag(), event.dTag())
|
||||
CodeName(event.address().toTag(), PeopleListName(it.value), CodeNameType.PEOPLE_LIST)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedBy { it.second }.toImmutableList()
|
||||
}.sortedBy { it.name.name() }.toImmutableList()
|
||||
|
||||
if (!equalImmutableLists(_followLists.value, newFollowLists)) {
|
||||
_followLists.emit(newFollowLists)
|
||||
if (!equalImmutableLists(_peopleLists.value, newFollowLists)) {
|
||||
_peopleLists.emit(newFollowLists)
|
||||
}
|
||||
|
||||
val communities = account.userProfile().cachedFollowingCommunitiesSet().mapNotNull {
|
||||
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
|
||||
CodeName("Community/${communityNote.idHex}", CommunityName(communityNote), CodeNameType.ROUTE)
|
||||
}
|
||||
}
|
||||
|
||||
val hashtags = account.userProfile().cachedFollowingTagSet().map {
|
||||
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE)
|
||||
}
|
||||
|
||||
val geotags = account.userProfile().cachedFollowingGeohashSet().map {
|
||||
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE)
|
||||
}
|
||||
|
||||
val routeList = (communities + hashtags + geotags).sortedBy { it.name.name() }.toImmutableList()
|
||||
|
||||
if (!equalImmutableLists(_routes.value, routeList)) {
|
||||
_routes.emit(routeList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,7 +667,10 @@ class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
checkNotInMainThread()
|
||||
if (newNotes.any { it.event is PeopleListEvent }) {
|
||||
if (newNotes.any {
|
||||
it.event?.pubKey() == account.userProfile().pubkeyHex && (it.event is PeopleListEvent || it.event is ContactListEvent)
|
||||
}
|
||||
) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
@@ -614,8 +692,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
@Composable
|
||||
fun SimpleTextSpinner(
|
||||
placeholder: String,
|
||||
options: ImmutableList<String>,
|
||||
explainers: ImmutableList<String>? = null,
|
||||
options: ImmutableList<Name>,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -651,10 +728,68 @@ fun SimpleTextSpinner(
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
SpinnerSelectionDialog(
|
||||
options = options,
|
||||
onDismiss = { optionsShowing = false },
|
||||
onSelect = {
|
||||
currentText = options[it].name()
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
}
|
||||
) {
|
||||
RenderOption(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderOption(it: Name) {
|
||||
when (it) {
|
||||
is GeoHashName -> {
|
||||
LoadCityName(it.geoHashTag) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = "/g/$it", color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
is HashtagName -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it.name(), color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
}
|
||||
is ResourceName -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = it.resourceId), color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
}
|
||||
is PeopleListName -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it.name(), color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
}
|
||||
is CommunityName -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val name by it.note.live().metadata.map {
|
||||
"/n/" + (it.note as? AddressableNote)?.dTag()
|
||||
}.observeAsState()
|
||||
|
||||
Text(text = name ?: "", color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -63,7 +64,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
@@ -72,11 +72,12 @@ import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
@@ -256,7 +257,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LoadStatuses(user = baseAccountUser) { statuses ->
|
||||
LoadStatuses(user = baseAccountUser, accountViewModel) { statuses ->
|
||||
if (statuses.isEmpty()) {
|
||||
val currentStatus = remember {
|
||||
mutableStateOf("")
|
||||
@@ -454,7 +455,6 @@ fun ListContent(
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -490,7 +490,7 @@ fun ListContent(
|
||||
)
|
||||
|
||||
IconRowRelays(
|
||||
relayViewModel = relayViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
@@ -633,27 +633,37 @@ private fun enableTor(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayStatus(
|
||||
relayViewModel: RelayPoolViewModel
|
||||
) {
|
||||
val connectedRelaysText by relayViewModel.connectionStatus.observeAsState("--/--")
|
||||
val isConnected by relayViewModel.isConnected.observeAsState(false)
|
||||
private fun RelayStatus(accountViewModel: AccountViewModel) {
|
||||
val connectedRelaysText by RelayPool.statusFlow.collectAsState(initial = RelayPoolStatus(0, 0))
|
||||
|
||||
RenderRelayStatus(connectedRelaysText, isConnected)
|
||||
RenderRelayStatus(connectedRelaysText)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRelayStatus(
|
||||
connectedRelaysText: String,
|
||||
isConnected: Boolean
|
||||
relayPool: RelayPoolStatus
|
||||
) {
|
||||
val text by remember(relayPool) {
|
||||
derivedStateOf {
|
||||
"${relayPool.connected}/${relayPool.available}"
|
||||
}
|
||||
}
|
||||
|
||||
val placeHolder = MaterialTheme.colors.placeholderText
|
||||
|
||||
val color by remember(relayPool) {
|
||||
derivedStateOf {
|
||||
if (relayPool.isConnected) {
|
||||
placeHolder
|
||||
} else {
|
||||
Color.Red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = connectedRelaysText,
|
||||
color = if (isConnected) {
|
||||
MaterialTheme.colors.placeholderText
|
||||
} else {
|
||||
Color.Red
|
||||
},
|
||||
text = text,
|
||||
color = color,
|
||||
style = MaterialTheme.typography.subtitle1
|
||||
)
|
||||
}
|
||||
@@ -709,7 +719,7 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongCl
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
|
||||
fun IconRowRelays(accountViewModel: AccountViewModel, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -736,7 +746,7 @@ fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
|
||||
|
||||
Spacer(modifier = Modifier.width(Size16dp))
|
||||
|
||||
RelayStatus(relayViewModel = relayViewModel)
|
||||
RelayStatus(accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -36,6 +37,8 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AddBountyAmountViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
@@ -80,6 +83,7 @@ class AddBountyAmountViewModel : ViewModel() {
|
||||
fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onClose: () -> Unit) {
|
||||
val postViewModel: AddBountyAmountViewModel = viewModel()
|
||||
postViewModel.load(accountViewModel.account, bounty)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
@@ -89,7 +93,11 @@ fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onCl
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp).width(IntrinsicSize.Min)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.width(IntrinsicSize.Min)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -102,8 +110,10 @@ fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onCl
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.hasChanged()
|
||||
)
|
||||
|
||||
@@ -646,7 +646,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
fun RenderChannelThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteEvent = baseNote.event as? ChannelCreateEvent ?: return
|
||||
|
||||
LoadChannel(baseChannelHex = baseNote.idHex) {
|
||||
LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) {
|
||||
RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ import androidx.lifecycle.map
|
||||
import com.patrykandpatrick.vico.core.extension.forEachIndexedExtended
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
@@ -99,7 +98,7 @@ fun ChatroomComposeChannelOrUser(
|
||||
}
|
||||
|
||||
if (channelHex != null) {
|
||||
ChatroomChannel(channelHex, baseNote, accountViewModel, nav)
|
||||
ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav)
|
||||
} else {
|
||||
ChatroomPrivateMessages(baseNote, accountViewModel, nav)
|
||||
}
|
||||
@@ -134,12 +133,12 @@ private fun ChatroomPrivateMessages(
|
||||
|
||||
@Composable
|
||||
private fun ChatroomChannel(
|
||||
channelHex: HexKey?,
|
||||
channelHex: HexKey,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadChannel(baseChannelHex = channelHex!!) { channel ->
|
||||
LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel ->
|
||||
ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
@@ -256,11 +255,10 @@ private fun UserRoomCompose(
|
||||
note.createdAt()
|
||||
}
|
||||
}
|
||||
|
||||
val content by remember(note) {
|
||||
derivedStateOf {
|
||||
mutableStateOf(
|
||||
accountViewModel.decrypt(note)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
|
||||
@@ -278,7 +276,7 @@ private fun UserRoomCompose(
|
||||
)
|
||||
},
|
||||
channelTitle = {
|
||||
RoomNameDisplay(room, it, accountViewModel.userProfile())
|
||||
RoomNameDisplay(room, it, accountViewModel)
|
||||
},
|
||||
channelLastTime = createAt,
|
||||
channelLastContent = content,
|
||||
@@ -288,20 +286,20 @@ private fun UserRoomCompose(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, loggedInUser: User) {
|
||||
val roomSubject by loggedInUser.live().messages.map {
|
||||
fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, accountViewModel: AccountViewModel) {
|
||||
val roomSubject by accountViewModel.userProfile().live().messages.map {
|
||||
it.user.privateChatrooms[room]?.subject
|
||||
}.distinctUntilChanged().observeAsState(loggedInUser.privateChatrooms[room]?.subject)
|
||||
}.distinctUntilChanged().observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
|
||||
|
||||
Crossfade(targetState = roomSubject, modifier) {
|
||||
if (it != null && it.isNotBlank()) {
|
||||
if (room.users.size > 1) {
|
||||
DisplayRoomSubject(it)
|
||||
} else {
|
||||
DisplayUserAndSubject(room.users.first(), it)
|
||||
DisplayUserAndSubject(room.users.first(), it, accountViewModel)
|
||||
}
|
||||
} else {
|
||||
DisplayUserSetAsSubject(room)
|
||||
DisplayUserSetAsSubject(room, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,7 +307,8 @@ fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, loggedInUser: User) {
|
||||
@Composable
|
||||
private fun DisplayUserAndSubject(
|
||||
user: HexKey,
|
||||
subject: String
|
||||
subject: String,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row() {
|
||||
Text(
|
||||
@@ -323,7 +322,7 @@ private fun DisplayUserAndSubject(
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
LoadUser(baseUserHex = user) {
|
||||
LoadUser(baseUserHex = user, accountViewModel = accountViewModel) {
|
||||
it?.let {
|
||||
UsernameDisplay(it, Modifier.weight(1f))
|
||||
}
|
||||
@@ -334,6 +333,7 @@ private fun DisplayUserAndSubject(
|
||||
@Composable
|
||||
fun DisplayUserSetAsSubject(
|
||||
room: ChatroomKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
fontWeight: FontWeight = FontWeight.Bold
|
||||
) {
|
||||
val userList = remember(room) {
|
||||
@@ -343,7 +343,7 @@ fun DisplayUserSetAsSubject(
|
||||
if (userList.size == 1) {
|
||||
// Regular Design
|
||||
Row() {
|
||||
LoadUser(baseUserHex = userList[0]) {
|
||||
LoadUser(baseUserHex = userList[0], accountViewModel) {
|
||||
it?.let {
|
||||
UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight)
|
||||
}
|
||||
@@ -352,7 +352,7 @@ fun DisplayUserSetAsSubject(
|
||||
} else {
|
||||
Row() {
|
||||
userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value ->
|
||||
LoadUser(baseUserHex = value) {
|
||||
LoadUser(baseUserHex = value, accountViewModel) {
|
||||
it?.let {
|
||||
ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight)
|
||||
}
|
||||
@@ -417,15 +417,14 @@ private fun WatchNotificationChanges(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadUser(baseUserHex: String, content: @Composable (User?) -> Unit) {
|
||||
fun LoadUser(baseUserHex: String, accountViewModel: AccountViewModel, content: @Composable (User?) -> Unit) {
|
||||
var user by remember(baseUserHex) {
|
||||
mutableStateOf(LocalCache.getUserIfExists(baseUserHex))
|
||||
mutableStateOf(accountViewModel.getUserIfExists(baseUserHex))
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
LaunchedEffect(key1 = baseUserHex) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newUser = LocalCache.checkGetOrCreateUser(baseUserHex)
|
||||
accountViewModel.checkGetOrCreateUser(baseUserHex) { newUser ->
|
||||
if (user != newUser) {
|
||||
user = newUser
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -66,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
@@ -285,7 +287,7 @@ fun NormalChatNote(
|
||||
modifier = modif,
|
||||
horizontalArrangement = alignment
|
||||
) {
|
||||
val availableBubbleSize = remember { mutableStateOf(0) }
|
||||
val availableBubbleSize = remember { mutableIntStateOf(0) }
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val modif2 = remember {
|
||||
@@ -355,7 +357,7 @@ private fun RenderBubble(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val bubbleSize = remember { mutableStateOf(0) }
|
||||
val bubbleSize = remember { mutableIntStateOf(0) }
|
||||
|
||||
val bubbleModifier = remember {
|
||||
Modifier
|
||||
@@ -626,8 +628,8 @@ private fun RenderRegularTextNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
|
||||
val eventContent = remember { accountViewModel.decrypt(note) }
|
||||
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
val eventContent by remember { mutableStateOf(accountViewModel.decrypt(note)) }
|
||||
val modifier = remember { Modifier.padding(top = 5.dp) }
|
||||
|
||||
if (eventContent != null) {
|
||||
@@ -636,7 +638,7 @@ private fun RenderRegularTextNote(
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent,
|
||||
content = eventContent!!,
|
||||
canPreview = canPreview,
|
||||
modifier = modifier,
|
||||
tags = tags,
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -67,8 +68,9 @@ import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
|
||||
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -169,7 +171,6 @@ private fun Galeries(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
|
||||
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
|
||||
val likeEvents by remember { derivedStateOf { multiSetCard.likeEventsByType } }
|
||||
|
||||
@@ -178,6 +179,16 @@ private fun Galeries(
|
||||
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
|
||||
|
||||
if (hasZapEvents) {
|
||||
var zapEvents by remember(multiSetCard.zapEvents) {
|
||||
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountViewModel.decryptAmountMessageInGroup(multiSetCard.zapEvents) {
|
||||
zapEvents = it
|
||||
}
|
||||
}
|
||||
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
@@ -247,7 +258,7 @@ fun RenderLikeGallery(
|
||||
|
||||
@Composable
|
||||
fun RenderZapGallery(
|
||||
zapEvents: ImmutableList<CombinedZap>,
|
||||
zapEvents: ImmutableList<ZapAmountCommentNotification>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
@@ -294,10 +305,29 @@ fun RenderBoostGallery(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapZaps(
|
||||
zaps: ImmutableList<CombinedZap>,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable (ImmutableList<ZapAmountCommentNotification>) -> Unit
|
||||
) {
|
||||
var zapEvents by remember(zaps) {
|
||||
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountViewModel.decryptAmountMessageInGroup(zaps) {
|
||||
zapEvents = it
|
||||
}
|
||||
}
|
||||
|
||||
content(zapEvents)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AuthorGalleryZaps(
|
||||
authorNotes: ImmutableList<CombinedZap>,
|
||||
authorNotes: ImmutableList<ZapAmountCommentNotification>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
@@ -305,24 +335,12 @@ fun AuthorGalleryZaps(
|
||||
Column(modifier = StdStartPadding) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
ParseAuthorCommentAndAmount(it, backgroundColor, nav, accountViewModel)
|
||||
RenderState(it, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParseAuthorCommentAndAmount(
|
||||
zap: CombinedZap,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
ParseAuthorCommentAndAmount(zap.request, zap.response, accountViewModel) { state ->
|
||||
RenderState(state, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ZapAmountCommentNotification(
|
||||
val user: User?,
|
||||
@@ -358,19 +376,21 @@ private fun ParseAuthorCommentAndAmount(
|
||||
onReady(content)
|
||||
}
|
||||
|
||||
fun click(content: ZapAmountCommentNotification, nav: (String) -> Unit) {
|
||||
content.user?.let {
|
||||
nav(routeFor(it))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderState(
|
||||
content: MutableState<ZapAmountCommentNotification>,
|
||||
content: ZapAmountCommentNotification,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickable {
|
||||
content.value.user?.let {
|
||||
nav(routeFor(it))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable { click(content, nav) },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DisplayAuthorCommentAndAmount(
|
||||
@@ -396,76 +416,65 @@ val commentTextSize = 12.sp
|
||||
|
||||
@Composable
|
||||
private fun DisplayAuthorCommentAndAmount(
|
||||
authorComment: MutableState<ZapAmountCommentNotification>,
|
||||
authorComment: ZapAmountCommentNotification,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) {
|
||||
CrossfadeToDisplayPicture(authorComment, accountViewModel)
|
||||
CrossfadeToDisplayAmount(authorComment)
|
||||
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(authorComment.user, accountViewModel)
|
||||
authorComment.amount?.let {
|
||||
CrossfadeToDisplayAmount(it)
|
||||
}
|
||||
}
|
||||
|
||||
CrossfadeToDisplayComment(authorComment, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CrossfadeToDisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
|
||||
Crossfade(authorComment.value) {
|
||||
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(it.user, accountViewModel)
|
||||
authorComment.comment?.let {
|
||||
CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CrossfadeToDisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
|
||||
Crossfade(authorComment.value, modifier = amountBoxModifier) {
|
||||
it.amount?.let {
|
||||
Box(
|
||||
modifier = amountBoxModifier,
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
val backgroundColor = MaterialTheme.colors.overPictureBackground
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(Size35dp)
|
||||
.background(backgroundColor)
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.bitcoinColor,
|
||||
fontSize = commentTextSize,
|
||||
modifier = bottomPadding1dp
|
||||
)
|
||||
}
|
||||
}
|
||||
fun CrossfadeToDisplayAmount(amount: String) {
|
||||
Box(
|
||||
modifier = amountBoxModifier,
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
val backgroundColor = MaterialTheme.colors.overPictureBackground
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(Size35dp)
|
||||
.background(backgroundColor)
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Text(
|
||||
text = amount,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.bitcoinColor,
|
||||
fontSize = commentTextSize,
|
||||
modifier = bottomPadding1dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CrossfadeToDisplayComment(
|
||||
authorComment: MutableState<ZapAmountCommentNotification>,
|
||||
comment: String,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Crossfade(authorComment.value) {
|
||||
it.comment?.let {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
tags = remember { ImmutableListOfLists() },
|
||||
modifier = textBoxModifier,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
TranslatableRichTextViewer(
|
||||
content = comment,
|
||||
canPreview = true,
|
||||
tags = EmptyTagList,
|
||||
modifier = textBoxModifier,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -517,25 +526,25 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
WatchUserMetadata(author) { baseUserPicture ->
|
||||
Crossfade(targetState = baseUserPicture) { userPicture ->
|
||||
RobohashAsyncImageProxy(
|
||||
robot = author.pubkeyHex,
|
||||
model = userPicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = MaterialTheme.colors.profile35dpModifier,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
// Crossfade(targetState = baseUserPicture) { userPicture ->
|
||||
RobohashAsyncImageProxy(
|
||||
robot = author.pubkeyHex,
|
||||
model = baseUserPicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = MaterialTheme.colors.profile35dpModifier,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
// }
|
||||
}
|
||||
|
||||
WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing ->
|
||||
Crossfade(targetState = isFollowing) {
|
||||
if (it) {
|
||||
Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) {
|
||||
FollowingIcon(Size10dp)
|
||||
}
|
||||
// Crossfade(targetState = isFollowing) {
|
||||
if (isFollowing) {
|
||||
Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) {
|
||||
FollowingIcon(Size10dp)
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ fun ObserveDisplayNip05Status(
|
||||
) {
|
||||
val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05())
|
||||
|
||||
LoadStatuses(baseUser) { statuses ->
|
||||
LoadStatuses(baseUser, accountViewModel) { statuses ->
|
||||
Crossfade(targetState = nip05, modifier = columnModifier, label = "ObserveDisplayNip05StatusCrossfade") {
|
||||
VerifyAndDisplayNIP05OrStatusLine(it, statuses, baseUser, columnModifier, accountViewModel, nav)
|
||||
}
|
||||
@@ -233,7 +233,7 @@ fun DisplayStatus(
|
||||
)
|
||||
}
|
||||
} else if (nostrATag != null) {
|
||||
LoadAddressableNote(nostrATag) { note ->
|
||||
LoadAddressableNote(nostrATag, accountViewModel) { note ->
|
||||
if (note != null) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
IconButton(
|
||||
@@ -255,7 +255,7 @@ fun DisplayStatus(
|
||||
}
|
||||
}
|
||||
} else if (nostrHexID != null) {
|
||||
LoadNote(baseNoteHex = nostrHexID) {
|
||||
LoadNote(baseNoteHex = nostrHexID, accountViewModel) {
|
||||
if (it != null) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
IconButton(
|
||||
|
||||
@@ -31,11 +31,15 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -50,6 +54,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Alignment.Companion.TopStart
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -80,8 +85,8 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
|
||||
@@ -116,11 +121,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
|
||||
@@ -128,11 +135,12 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
@@ -145,13 +153,13 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyBackground
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.repostProfileBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
@@ -172,11 +180,12 @@ import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiUrl
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
@@ -623,7 +632,7 @@ fun LongCommunityHeader(
|
||||
TranslatableRichTextViewer(
|
||||
content = summary ?: stringResource(id = R.string.community_no_descriptor),
|
||||
canPreview = false,
|
||||
tags = remember { ImmutableListOfLists(emptyList()) },
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = background,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
@@ -665,14 +674,13 @@ fun LongCommunityHeader(
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = noteState) {
|
||||
launch(Dispatchers.IO) {
|
||||
val noteEvent = (noteState?.note?.event as? CommunityDefinitionEvent)
|
||||
val newParticipantUsers = noteEvent?.moderators()?.mapNotNull { part ->
|
||||
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
|
||||
}?.toImmutableList()
|
||||
val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators()
|
||||
|
||||
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
|
||||
participantUsers = newParticipantUsers
|
||||
if (participants != null) {
|
||||
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
|
||||
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
|
||||
participantUsers = newParticipantUsers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1034,7 +1042,9 @@ private fun NoteBody(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
if (baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) {
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
}
|
||||
|
||||
if (!makeItShort) {
|
||||
ReplyRow(
|
||||
@@ -1054,6 +1064,62 @@ private fun NoteBody(
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
|
||||
val noteEvent = baseNote.event
|
||||
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
|
||||
if (zapSplits && noteEvent != null) {
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
DisplayZapSplits(noteEvent, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DisplayZapSplits(noteEvent: EventInterface, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val list = remember(noteEvent) { noteEvent.zapSplitSetup() }
|
||||
|
||||
if (list.isEmpty()) return
|
||||
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowForwardIos,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
FlowRow {
|
||||
list.forEach {
|
||||
if (it.isLnAddress) {
|
||||
ClickableText(
|
||||
text = AnnotatedString(it.lnAddressOrPubKeyHex),
|
||||
onClick = { },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else {
|
||||
UserPicture(userHex = it.lnAddressOrPubKeyHex, size = 25.dp, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1278,7 +1344,7 @@ fun RenderTextEvent(
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
val modifier = remember(note) { Modifier.fillMaxWidth() }
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent,
|
||||
@@ -1317,7 +1383,7 @@ fun RenderPoll(
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
} else {
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
SensitivityWarning(
|
||||
note = note,
|
||||
@@ -1492,7 +1558,7 @@ fun RenderAppDefinition(
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
|
||||
) {
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
val bgColor = MaterialTheme.colors.background
|
||||
val backgroundColor = remember {
|
||||
mutableStateOf(bgColor)
|
||||
@@ -1559,20 +1625,18 @@ private fun RenderPrivateMessage(
|
||||
val noteEvent = note.event as? PrivateDmEvent ?: return
|
||||
|
||||
val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
|
||||
|
||||
if (withMe) {
|
||||
val eventContent = remember { accountViewModel.decrypt(note) }
|
||||
|
||||
val eventContent by remember { mutableStateOf(accountViewModel.decrypt(note)) }
|
||||
val hashtags = remember(note.event?.id()) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
|
||||
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
|
||||
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
|
||||
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
if (eventContent != null) {
|
||||
if (makeItShort && isAuthorTheLoggedUser) {
|
||||
Text(
|
||||
text = eventContent,
|
||||
text = eventContent!!,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -1583,7 +1647,7 @@ private fun RenderPrivateMessage(
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent,
|
||||
content = eventContent!!,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
modifier = modifier,
|
||||
tags = tags,
|
||||
@@ -1593,7 +1657,7 @@ private fun RenderPrivateMessage(
|
||||
)
|
||||
}
|
||||
|
||||
DisplayUncitedHashtags(hashtags, eventContent, nav)
|
||||
DisplayUncitedHashtags(hashtags, eventContent!!, nav)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1607,7 +1671,7 @@ private fun RenderPrivateMessage(
|
||||
),
|
||||
canPreview = !makeItShort,
|
||||
Modifier.fillMaxWidth(),
|
||||
ImmutableListOfLists(),
|
||||
EmptyTagList,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
@@ -1624,9 +1688,9 @@ fun DisplayRelaySet(
|
||||
) {
|
||||
val noteEvent = baseNote.event as? RelaySetEvent ?: return
|
||||
|
||||
val relays by remember {
|
||||
mutableStateOf<ImmutableList<String>>(
|
||||
noteEvent.relays().toImmutableList()
|
||||
val relays by remember(baseNote) {
|
||||
mutableStateOf(
|
||||
noteEvent.relays().map { RelayBriefInfo(it) }.toImmutableList()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1681,7 +1745,7 @@ fun DisplayRelaySet(
|
||||
toMembersShow.forEach { relay ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
|
||||
Text(
|
||||
relay.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/"),
|
||||
text = relay.displayUrl,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -1691,7 +1755,7 @@ fun DisplayRelaySet(
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
RelayOptionsAction(relay, accountViewModel, nav)
|
||||
RelayOptionsAction(relay.url, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1752,9 +1816,8 @@ fun DisplayPeopleList(
|
||||
) {
|
||||
val noteEvent = baseNote.event as? PeopleListEvent ?: return
|
||||
|
||||
var members by remember { mutableStateOf<List<User>>(listOf()) }
|
||||
var members by remember { mutableStateOf<ImmutableList<User>>(persistentListOf()) }
|
||||
|
||||
val account = accountViewModel.userProfile()
|
||||
var expanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -1783,10 +1846,8 @@ fun DisplayPeopleList(
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
members = noteEvent.bookmarkedPeople().mapNotNull { hex ->
|
||||
LocalCache.checkGetOrCreateUser(hex)
|
||||
}.sortedBy { account.isFollowing(it) }.reversed()
|
||||
accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) {
|
||||
members = it
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1834,15 +1895,11 @@ private fun RenderBadgeAward(
|
||||
val noteEvent = note.event as? BadgeAwardEvent ?: return
|
||||
var awardees by remember { mutableStateOf<List<User>>(listOf()) }
|
||||
|
||||
val account = accountViewModel.userProfile()
|
||||
|
||||
Text(text = stringResource(R.string.award_granted_to))
|
||||
|
||||
LaunchedEffect(key1 = note) {
|
||||
launch(Dispatchers.IO) {
|
||||
awardees = noteEvent.awardees().mapNotNull { hex ->
|
||||
LocalCache.checkGetOrCreateUser(hex)
|
||||
}.sortedBy { account.isFollowing(it) }.reversed()
|
||||
accountViewModel.loadUsers(noteEvent.awardees()) {
|
||||
awardees = it
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1951,7 +2008,7 @@ fun RenderPostApproval(
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
noteEvent.communities().forEach {
|
||||
LoadAddressableNote(it) {
|
||||
LoadAddressableNote(it, accountViewModel) {
|
||||
it?.let {
|
||||
NoteCompose(
|
||||
it,
|
||||
@@ -1990,15 +2047,14 @@ fun RenderPostApproval(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadAddressableNote(aTagHex: String, content: @Composable (AddressableNote?) -> Unit) {
|
||||
fun LoadAddressableNote(aTagHex: String, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit) {
|
||||
var note by remember(aTagHex) {
|
||||
mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTagHex))
|
||||
mutableStateOf<AddressableNote?>(accountViewModel.getAddressableNoteIfExists(aTagHex))
|
||||
}
|
||||
|
||||
if (note == null) {
|
||||
LaunchedEffect(key1 = aTagHex) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newNote = LocalCache.checkGetOrCreateAddressableNote(aTagHex)
|
||||
accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote ->
|
||||
if (newNote != note) {
|
||||
note = newNote
|
||||
}
|
||||
@@ -2010,15 +2066,14 @@ fun LoadAddressableNote(aTagHex: String, content: @Composable (AddressableNote?)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadAddressableNote(aTag: ATag, content: @Composable (AddressableNote?) -> Unit) {
|
||||
fun LoadAddressableNote(aTag: ATag, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit) {
|
||||
var note by remember(aTag) {
|
||||
mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTag.toTag()))
|
||||
mutableStateOf<AddressableNote?>(accountViewModel.getAddressableNoteIfExists(aTag.toTag()))
|
||||
}
|
||||
|
||||
if (note == null) {
|
||||
LaunchedEffect(key1 = aTag) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newNote = LocalCache.getOrCreateAddressableNote(aTag)
|
||||
accountViewModel.getOrCreateAddressableNote(aTag) { newNote ->
|
||||
if (newNote != note) {
|
||||
note = newNote
|
||||
}
|
||||
@@ -2150,7 +2205,8 @@ private fun EmojiListOptions(
|
||||
accountViewModel.userProfile().pubkeyHex,
|
||||
"",
|
||||
null
|
||||
)
|
||||
),
|
||||
accountViewModel
|
||||
) {
|
||||
it?.let { usersEmojiList ->
|
||||
val hasAddedThis by usersEmojiList.live().metadata.map {
|
||||
@@ -2253,7 +2309,7 @@ fun RenderPinListEvent(
|
||||
TranslatableRichTextViewer(
|
||||
content = pin,
|
||||
canPreview = true,
|
||||
tags = remember { ImmutableListOfLists() },
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
@@ -2352,8 +2408,8 @@ private fun RenderReport(
|
||||
TranslatableRichTextViewer(
|
||||
content = content,
|
||||
canPreview = true,
|
||||
modifier = remember { Modifier },
|
||||
tags = remember { ImmutableListOfLists() },
|
||||
modifier = Modifier,
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
@@ -2397,40 +2453,41 @@ private fun ReplyRow(
|
||||
}
|
||||
}
|
||||
|
||||
val showChannelInfo by remember(note) {
|
||||
derivedStateOf {
|
||||
if (noteEvent is ChannelMessageEvent || noteEvent is LiveActivitiesChatMessageEvent) {
|
||||
note.channelHex()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showChannelInfo?.let {
|
||||
ChannelHeader(
|
||||
channelHex = it,
|
||||
showVideo = false,
|
||||
showBottomDiviser = false,
|
||||
sendToChannel = true,
|
||||
modifier = MaterialTheme.colors.replyModifier.padding(10.dp),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
if (showReply) {
|
||||
val replyingDirectlyTo = remember { note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } }
|
||||
if (replyingDirectlyTo != null && unPackReply) {
|
||||
ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
} else {
|
||||
// ReplyInformation(note.replyTo, noteEvent.mentions(), accountViewModel, nav)
|
||||
}
|
||||
} else {
|
||||
val showChannelReply by remember {
|
||||
derivedStateOf {
|
||||
(noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) ||
|
||||
(noteEvent is LiveActivitiesChatMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()))
|
||||
} else if (showChannelInfo != null) {
|
||||
val replies = remember { note.replyTo?.toImmutableList() }
|
||||
val mentions = remember {
|
||||
(note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList()
|
||||
?: persistentListOf()
|
||||
}
|
||||
}
|
||||
|
||||
if (showChannelReply) {
|
||||
val channelHex = note.channelHex()
|
||||
channelHex?.let {
|
||||
ChannelHeader(
|
||||
channelHex = channelHex,
|
||||
showVideo = false,
|
||||
showBottomDiviser = false,
|
||||
sendToChannel = true,
|
||||
modifier = remember { Modifier.padding(vertical = 5.dp) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
val replies = remember { note.replyTo?.toImmutableList() }
|
||||
val mentions = remember { (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf() }
|
||||
|
||||
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
|
||||
}
|
||||
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2501,6 +2558,7 @@ fun SecondUserInfoRow(
|
||||
@Composable
|
||||
fun LoadStatuses(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable (ImmutableList<AddressableNote>) -> Unit
|
||||
) {
|
||||
var statuses: ImmutableList<AddressableNote> by remember {
|
||||
@@ -2510,9 +2568,7 @@ fun LoadStatuses(
|
||||
val userStatus by user.live().statuses.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = userStatus) {
|
||||
launch(Dispatchers.IO) {
|
||||
val myUser = userStatus?.user ?: return@launch
|
||||
val newStatuses = LocalCache.findStatusesForUser(myUser)
|
||||
accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses ->
|
||||
if (!equalImmutableLists(statuses, newStatuses)) {
|
||||
statuses = newStatuses
|
||||
}
|
||||
@@ -2523,7 +2579,7 @@ fun LoadStatuses(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
|
||||
fun LoadCityName(geohash: String, content: @Composable (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var cityName by remember(geohash) {
|
||||
mutableStateOf<String>(geohash)
|
||||
@@ -2538,18 +2594,25 @@ fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(cityName),
|
||||
onClick = { nav("Geohash/$geohash") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colors.primary.copy(
|
||||
alpha = 0.52f
|
||||
content(cityName)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
|
||||
LoadCityName(geohash) { cityName ->
|
||||
ClickableText(
|
||||
text = AnnotatedString(cityName),
|
||||
onClick = { nav("Geohash/$geohash") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colors.primary.copy(
|
||||
alpha = 0.52f
|
||||
),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -2572,12 +2635,14 @@ fun FirstUserInfoRow(
|
||||
}
|
||||
}
|
||||
|
||||
val textColor = if (isRepost) MaterialTheme.colors.grayText else Color.Unspecified
|
||||
|
||||
if (showAuthorPicture) {
|
||||
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
|
||||
Spacer(HalfPadding)
|
||||
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
|
||||
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor)
|
||||
} else {
|
||||
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
|
||||
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor)
|
||||
}
|
||||
|
||||
if (isRepost) {
|
||||
@@ -2663,14 +2728,14 @@ private fun BadgeBox(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isRepost by remember {
|
||||
val isRepost by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
|
||||
}
|
||||
}
|
||||
|
||||
if (isRepost) {
|
||||
val baseReply by remember {
|
||||
val baseReply by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
baseNote.replyTo?.lastOrNull()
|
||||
}
|
||||
@@ -2689,12 +2754,18 @@ private fun RenderAuthorImages(
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp)
|
||||
val baseRepost by remember {
|
||||
derivedStateOf {
|
||||
baseNote.replyTo?.lastOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
|
||||
|
||||
if (isRepost) {
|
||||
RepostNoteAuthorPicture(baseNote, accountViewModel, nav)
|
||||
if (isRepost && baseRepost != null) {
|
||||
RepostNoteAuthorPicture(baseNote, baseRepost!!, accountViewModel, nav)
|
||||
} else {
|
||||
NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp)
|
||||
}
|
||||
|
||||
val isChannel = baseNote.event is ChannelMessageEvent && baseNote.channelHex() != null
|
||||
@@ -2702,7 +2773,7 @@ private fun RenderAuthorImages(
|
||||
if (isChannel) {
|
||||
val baseChannelHex = remember { baseNote.channelHex() }
|
||||
if (baseChannelHex != null) {
|
||||
LoadChannel(baseChannelHex) { channel ->
|
||||
LoadChannel(baseChannelHex, accountViewModel) { channel ->
|
||||
ChannelNotePicture(channel)
|
||||
}
|
||||
}
|
||||
@@ -2710,15 +2781,14 @@ private fun RenderAuthorImages(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadChannel(baseChannelHex: String, content: @Composable (Channel) -> Unit) {
|
||||
fun LoadChannel(baseChannelHex: String, accountViewModel: AccountViewModel, content: @Composable (Channel) -> Unit) {
|
||||
var channel by remember(baseChannelHex) {
|
||||
mutableStateOf<Channel?>(LocalCache.getChannelIfExists(baseChannelHex))
|
||||
mutableStateOf<Channel?>(accountViewModel.getChannelIfExists(baseChannelHex))
|
||||
}
|
||||
|
||||
if (channel == null) {
|
||||
LaunchedEffect(key1 = baseChannelHex) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newChannel = LocalCache.checkGetOrCreateChannel(baseChannelHex)
|
||||
accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel ->
|
||||
launch(Dispatchers.Main) {
|
||||
channel = newChannel
|
||||
}
|
||||
@@ -2765,23 +2835,30 @@ private fun ChannelNotePicture(baseChannel: Channel) {
|
||||
@Composable
|
||||
private fun RepostNoteAuthorPicture(
|
||||
baseNote: Note,
|
||||
baseRepost: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val baseRepost by remember {
|
||||
derivedStateOf {
|
||||
baseNote.replyTo?.lastOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
baseRepost?.let {
|
||||
Box(Size30Modifier) {
|
||||
Box(modifier = Size55Modifier) {
|
||||
Box(Size35Modifier.align(Alignment.TopStart)) {
|
||||
NoteAuthorPicture(
|
||||
baseNote = it,
|
||||
baseNote = baseNote,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size30dp,
|
||||
pictureModifier = MaterialTheme.colors.repostProfileBorder
|
||||
size = Size34dp
|
||||
)
|
||||
}
|
||||
|
||||
Box(Size18Modifier.align(Alignment.BottomStart).padding(1.dp)) {
|
||||
RepostedIcon(modifier = Size18Modifier, MaterialTheme.colors.placeholderText)
|
||||
}
|
||||
|
||||
Box(Size35Modifier.align(Alignment.BottomEnd)) {
|
||||
NoteAuthorPicture(
|
||||
baseNote = baseRepost,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size34dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2811,7 +2888,7 @@ fun DisplayHighlight(
|
||||
quote,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
remember { Modifier.fillMaxWidth() },
|
||||
remember { ImmutableListOfLists<String>(emptyList()) },
|
||||
EmptyTagList,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
@@ -2828,15 +2905,12 @@ private fun DisplayQuoteAuthor(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var userBase by remember { mutableStateOf<User?>(LocalCache.getUserIfExists(authorHex)) }
|
||||
var userBase by remember { mutableStateOf<User?>(accountViewModel.getUserIfExists(authorHex)) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (userBase == null) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newUserBase = LocalCache.checkGetOrCreateUser(authorHex)
|
||||
launch(Dispatchers.Main) {
|
||||
userBase = newUserBase
|
||||
}
|
||||
if (userBase == null) {
|
||||
LaunchedEffect(Unit) {
|
||||
accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase ->
|
||||
userBase = newUserBase
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2860,7 +2934,7 @@ private fun DisplayQuoteAuthor(
|
||||
|
||||
@Composable
|
||||
private fun LoadAndDisplayPost(postAddress: ATag, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
LoadAddressableNote(aTag = postAddress) {
|
||||
LoadAddressableNote(aTag = postAddress, accountViewModel) {
|
||||
it?.let { note ->
|
||||
val noteEvent by note.live().metadata.map {
|
||||
it.note.event
|
||||
@@ -3306,7 +3380,7 @@ fun FileStorageHeaderDisplay(baseNote: Note, roundedCorner: Boolean, accountView
|
||||
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
|
||||
val dataEventId = eventHeader.dataEventId() ?: return
|
||||
|
||||
LoadNote(baseNoteHex = dataEventId) { contentNote ->
|
||||
LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote ->
|
||||
if (contentNote != null) {
|
||||
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
|
||||
}
|
||||
@@ -3381,8 +3455,8 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A
|
||||
var participantUsers by remember { mutableStateOf<List<Pair<Participant, User>>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(key1 = participants) {
|
||||
launch(Dispatchers.IO) {
|
||||
participantUsers = participants.mapNotNull { part -> LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } }
|
||||
accountViewModel.loadParticipants(participants) {
|
||||
participantUsers = it
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3461,7 +3535,7 @@ fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: Accou
|
||||
|
||||
val defaultBackground = MaterialTheme.colors.background
|
||||
val background = remember { mutableStateOf(defaultBackground) }
|
||||
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
|
||||
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
val eventContent = remember(note.event) {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
|
||||
@@ -3591,11 +3665,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = eventUpdates) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newParticipantUsers = participants.mapNotNull { part ->
|
||||
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
|
||||
}.toImmutableList()
|
||||
|
||||
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
|
||||
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
|
||||
participantUsers = newParticipantUsers
|
||||
}
|
||||
@@ -3676,7 +3746,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
|
||||
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountViewModel: AccountViewModel) {
|
||||
val image = remember(noteEvent) { noteEvent.image() }
|
||||
val title = remember(noteEvent) { noteEvent.title() }
|
||||
val summary = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } }
|
||||
val summary = remember(noteEvent) { noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
@@ -32,9 +33,12 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
@@ -113,7 +117,7 @@ private fun OptionNote(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val tags = remember(baseNote) {
|
||||
baseNote.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
|
||||
baseNote.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -283,6 +287,12 @@ fun ZapVote(
|
||||
}
|
||||
|
||||
var wantsToZap by remember { mutableStateOf(false) }
|
||||
var wantsToPay by remember {
|
||||
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
|
||||
persistentListOf()
|
||||
)
|
||||
}
|
||||
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -298,7 +308,7 @@ fun ZapVote(
|
||||
// interactionSource = remember { MutableInteractionSource() },
|
||||
// indication = rememberRipple(bounded = false, radius = 24.dp),
|
||||
onClick = {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
if (!accountViewModel.isWriteable() && !accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
@@ -362,6 +372,8 @@ fun ZapVote(
|
||||
zappingProgress = it
|
||||
}
|
||||
},
|
||||
onPayViaIntent = {
|
||||
},
|
||||
zapType = accountViewModel.account.defaultZapType
|
||||
)
|
||||
} else {
|
||||
@@ -393,10 +405,19 @@ fun ZapVote(
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
},
|
||||
onPayViaIntent = {
|
||||
wantsToPay = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (wantsToPay.isNotEmpty()) {
|
||||
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
|
||||
wantsToPay = persistentListOf()
|
||||
}
|
||||
}
|
||||
|
||||
if (showErrorMessageDialog != null) {
|
||||
ErrorMessageDialog(
|
||||
title = stringResource(id = R.string.error_dialog_zap_error),
|
||||
@@ -463,7 +484,8 @@ fun FilteredZapAmountChoicePopup(
|
||||
onDismiss: () -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
onError: (text: String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -502,6 +524,7 @@ fun FilteredZapAmountChoicePopup(
|
||||
context,
|
||||
onError,
|
||||
onProgress,
|
||||
onPayViaIntent,
|
||||
defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
@@ -526,6 +549,7 @@ fun FilteredZapAmountChoicePopup(
|
||||
context,
|
||||
onError,
|
||||
onProgress,
|
||||
onPayViaIntent,
|
||||
defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
|
||||
@@ -78,18 +78,17 @@ import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.TextType
|
||||
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DarkerGreen
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowExpandButton
|
||||
@@ -107,6 +106,8 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -133,13 +134,14 @@ fun ReactionsRow(
|
||||
|
||||
InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav)
|
||||
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel)
|
||||
|
||||
if (showReactionDetail && wantsToSeeReactions.value) {
|
||||
ReactionDetailGallery(baseNote, nav, accountViewModel)
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
}
|
||||
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -249,7 +251,6 @@ private fun LoadAndDisplayZapraiser(
|
||||
}
|
||||
|
||||
if (zapraiserAmount > 0) {
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
Box(
|
||||
modifier = remember {
|
||||
ReactionRowZapraiserSize
|
||||
@@ -457,8 +458,15 @@ private fun WatchZapAndRenderGallery(
|
||||
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() }
|
||||
|
||||
var zapEvents by remember(zapsState) {
|
||||
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
accountViewModel.decryptAmountMessageInGroup(baseNote) {
|
||||
zapEvents = it
|
||||
}
|
||||
}
|
||||
|
||||
if (zapEvents.isNotEmpty()) {
|
||||
@@ -543,12 +551,16 @@ fun ReplyReaction(
|
||||
if (accountViewModel.isWriteable()) {
|
||||
onPress()
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_reply),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
onPress()
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_reply),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -652,12 +664,22 @@ fun BoostReaction(
|
||||
wantsToBoost = true
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_boost_posts),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
if (accountViewModel.hasBoosted(baseNote)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.deleteBoostsTo(baseNote)
|
||||
}
|
||||
} else {
|
||||
wantsToBoost = true
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_boost_posts),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,6 +697,11 @@ fun BoostReaction(
|
||||
onQuote = {
|
||||
wantsToBoost = false
|
||||
onQuotePress()
|
||||
},
|
||||
onRepost = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.boost(baseNote)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -738,6 +765,20 @@ fun LikeReaction(
|
||||
context,
|
||||
onMultipleChoices = {
|
||||
wantsToReact = true
|
||||
},
|
||||
onWantsToSignReaction = {
|
||||
if (accountViewModel.account.reactionChoices.size == 1) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val reaction = accountViewModel.account.reactionChoices.first()
|
||||
if (accountViewModel.hasReactedTo(baseNote, reaction)) {
|
||||
accountViewModel.deleteReactionTo(baseNote, reaction)
|
||||
} else {
|
||||
accountViewModel.reactTo(baseNote, reaction)
|
||||
}
|
||||
}
|
||||
} else if (accountViewModel.account.reactionChoices.size > 1) {
|
||||
wantsToReact = true
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -857,7 +898,8 @@ private fun likeClick(
|
||||
accountViewModel: AccountViewModel,
|
||||
scope: CoroutineScope,
|
||||
context: Context,
|
||||
onMultipleChoices: () -> Unit
|
||||
onMultipleChoices: () -> Unit,
|
||||
onWantsToSignReaction: () -> Unit
|
||||
) {
|
||||
if (accountViewModel.account.reactionChoices.isEmpty()) {
|
||||
scope.launch {
|
||||
@@ -870,14 +912,18 @@ private fun likeClick(
|
||||
.show()
|
||||
}
|
||||
} else if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_like_posts),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
onWantsToSignReaction()
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_like_posts),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else if (accountViewModel.account.reactionChoices.size == 1) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -907,6 +953,11 @@ fun ZapReaction(
|
||||
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
|
||||
var wantsToSetCustomZap by remember { mutableStateOf(false) }
|
||||
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
|
||||
var wantsToPay by remember(baseNote) {
|
||||
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
|
||||
persistentListOf()
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -938,6 +989,9 @@ fun ZapReaction(
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = it
|
||||
}
|
||||
},
|
||||
onPayViaIntent = {
|
||||
wantsToPay = it
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -971,6 +1025,9 @@ fun ZapReaction(
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
},
|
||||
onPayViaIntent = {
|
||||
wantsToPay = it
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -981,7 +1038,10 @@ fun ZapReaction(
|
||||
textContent = showErrorMessageDialog ?: "",
|
||||
onClickStartMessage = {
|
||||
baseNote.author?.let {
|
||||
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel))
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val route = routeToMessage(it, showErrorMessageDialog, accountViewModel)
|
||||
nav(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = { showErrorMessageDialog = null }
|
||||
@@ -989,11 +1049,38 @@ fun ZapReaction(
|
||||
}
|
||||
|
||||
if (wantsToChangeZapAmount) {
|
||||
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel)
|
||||
UpdateZapAmountDialog(
|
||||
onClose = { wantsToChangeZapAmount = false },
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (wantsToPay.isNotEmpty()) {
|
||||
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
|
||||
wantsToPay = persistentListOf()
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsToSetCustomZap) {
|
||||
ZapCustomDialog({ wantsToSetCustomZap = false }, accountViewModel, baseNote)
|
||||
ZapCustomDialog(
|
||||
onClose = { wantsToSetCustomZap = false },
|
||||
onError = {
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = it
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
},
|
||||
onPayViaIntent = {
|
||||
wantsToPay = it
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
baseNote = baseNote
|
||||
)
|
||||
}
|
||||
|
||||
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
|
||||
@@ -1027,7 +1114,8 @@ private fun zapClick(
|
||||
context: Context,
|
||||
onZappingProgress: (Float) -> Unit,
|
||||
onMultipleChoices: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
|
||||
) {
|
||||
if (accountViewModel.account.zapAmountChoices.isEmpty()) {
|
||||
scope.launch {
|
||||
@@ -1039,7 +1127,7 @@ private fun zapClick(
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else if (!accountViewModel.isWriteable()) {
|
||||
} else if (!accountViewModel.isWriteable() && !accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
@@ -1062,7 +1150,8 @@ private fun zapClick(
|
||||
onZappingProgress(it)
|
||||
}
|
||||
},
|
||||
zapType = accountViewModel.account.defaultZapType
|
||||
zapType = accountViewModel.account.defaultZapType,
|
||||
onPayViaIntent = onPayViaIntent
|
||||
)
|
||||
} else if (accountViewModel.account.zapAmountChoices.size > 1) {
|
||||
onMultipleChoices()
|
||||
@@ -1178,7 +1267,7 @@ private fun DrawViewCount(
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
|
||||
private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit, onRepost: () -> Unit) {
|
||||
val iconSizePx = with(LocalDensity.current) {
|
||||
-iconSize.toPx().toInt()
|
||||
}
|
||||
@@ -1193,8 +1282,13 @@ private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel:
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.boost(baseNote)
|
||||
if (accountViewModel.isWriteable()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.boost(baseNote)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
onRepost()
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
@@ -1373,10 +1467,11 @@ fun ZapAmountChoicePopup(
|
||||
onDismiss: () -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
onError: (text: String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val zapMessage = ""
|
||||
@@ -1399,6 +1494,7 @@ fun ZapAmountChoicePopup(
|
||||
context,
|
||||
onError,
|
||||
onProgress,
|
||||
onPayViaIntent,
|
||||
account.defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
@@ -1423,6 +1519,7 @@ fun ZapAmountChoicePopup(
|
||||
context,
|
||||
onError,
|
||||
onProgress,
|
||||
onPayViaIntent,
|
||||
account.defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
|
||||
@@ -11,17 +11,16 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
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
|
||||
import androidx.compose.ui.Alignment
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
|
||||
@@ -29,80 +28,40 @@ import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconButtonModifie
|
||||
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
public fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showShowMore by remember { mutableStateOf(false) }
|
||||
|
||||
var lazyRelayList by remember {
|
||||
val baseNumber = baseNote.relays.map {
|
||||
it.removePrefix("wss://").removePrefix("ws://")
|
||||
}.toImmutableList()
|
||||
|
||||
mutableStateOf(baseNumber)
|
||||
}
|
||||
var shortRelayList by remember {
|
||||
mutableStateOf(lazyRelayList.take(3).toImmutableList())
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchRelayLists(baseNote) { relayList ->
|
||||
if (!equalImmutableLists(relayList, lazyRelayList)) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
lazyRelayList = relayList
|
||||
shortRelayList = relayList.take(3).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
val nextShowMore = relayList.size > 3
|
||||
if (nextShowMore != showShowMore) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// only triggers recomposition when actually different
|
||||
showShowMore = nextShowMore
|
||||
}
|
||||
val relayList by baseNote.live().relayInfo.observeAsState(persistentListOf())
|
||||
val shortRelayList by remember {
|
||||
derivedStateOf {
|
||||
relayList.take(3).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(DoubleVertSpacer)
|
||||
|
||||
if (expanded) {
|
||||
VerticalRelayPanelWithFlow(lazyRelayList, accountViewModel, nav)
|
||||
VerticalRelayPanelWithFlow(relayList, accountViewModel, nav)
|
||||
} else {
|
||||
VerticalRelayPanelWithFlow(shortRelayList, accountViewModel, nav)
|
||||
}
|
||||
|
||||
if (showShowMore && !expanded) {
|
||||
if (relayList.size > 3 && !expanded) {
|
||||
ShowMoreRelaysButton {
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchRelayLists(baseNote: Note, onListChanges: (ImmutableList<String>) -> Unit) {
|
||||
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = noteRelaysState) {
|
||||
launch(Dispatchers.IO) {
|
||||
val relayList = noteRelaysState?.note?.relays?.map {
|
||||
it.removePrefix("wss://").removePrefix("ws://")
|
||||
} ?: emptyList()
|
||||
|
||||
onListChanges(relayList.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@Stable
|
||||
private fun VerticalRelayPanelWithFlow(
|
||||
relays: ImmutableList<String>,
|
||||
relays: ImmutableList<RelayBriefInfo>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -17,7 +18,6 @@ import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -29,13 +29,15 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelayInformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
|
||||
@@ -44,6 +46,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
@@ -59,15 +63,13 @@ public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewMo
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RenderRelayList(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteRelays by baseNote.live().relays.map {
|
||||
it.note.relays
|
||||
}.observeAsState(baseNote.relays)
|
||||
val noteRelays by baseNote.live().relayInfo.observeAsState()
|
||||
|
||||
FlowRow(StdStartPadding) {
|
||||
val relaysToDisplay = remember(noteRelays, expanded.value) {
|
||||
if (expanded.value) noteRelays else noteRelays.take(3)
|
||||
if (expanded.value) noteRelays else noteRelays?.take(3)?.toImmutableList()
|
||||
}
|
||||
relaysToDisplay.forEach {
|
||||
relaysToDisplay?.forEach {
|
||||
RenderRelay(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
@@ -104,14 +106,7 @@ fun ChatRelayExpandButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val iconUrl by remember(dirtyUrl) {
|
||||
derivedStateOf {
|
||||
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
|
||||
"https://$cleanUrl/favicon.ico"
|
||||
}
|
||||
}
|
||||
|
||||
fun RenderRelay(relay: RelayBriefInfo, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
|
||||
|
||||
if (relayInfo != null) {
|
||||
@@ -120,8 +115,9 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
|
||||
relayInfo = null
|
||||
},
|
||||
relayInfo = relayInfo!!,
|
||||
accountViewModel,
|
||||
nav
|
||||
relayBriefInfo = relay,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,7 +126,7 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val ripple = rememberRipple(bounded = false, radius = Size15dp)
|
||||
|
||||
val clickableModifier = remember(dirtyUrl) {
|
||||
val clickableModifier = remember(relay) {
|
||||
Modifier
|
||||
.padding(1.dp)
|
||||
.size(Size15dp)
|
||||
@@ -139,9 +135,30 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple,
|
||||
onClick = {
|
||||
loadRelayInfo(dirtyUrl, context, scope) {
|
||||
relayInfo = it
|
||||
}
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
relay.url,
|
||||
onInfo = {
|
||||
relayInfo = it
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
val msg = when (errorCode) {
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
msg,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -149,17 +166,17 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
|
||||
Box(
|
||||
modifier = clickableModifier
|
||||
) {
|
||||
RenderRelayIcon(iconUrl)
|
||||
RenderRelayIcon(relay.favIcon)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRelayIcon(iconUrl: String) {
|
||||
fun RenderRelayIcon(iconUrl: String, size: Dp = Size13dp) {
|
||||
val backgroundColor = MaterialTheme.colors.background
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier
|
||||
.size(Size13dp)
|
||||
.size(size)
|
||||
.clip(shape = CircleShape)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
@@ -314,7 +314,8 @@ private fun EmojiSelector(accountViewModel: AccountViewModel, nav: (String) -> U
|
||||
accountViewModel.userProfile().pubkeyHex,
|
||||
"",
|
||||
null
|
||||
)
|
||||
),
|
||||
accountViewModel
|
||||
) { emptyNote ->
|
||||
emptyNote?.let { usersEmojiList ->
|
||||
val collections by usersEmojiList.live().metadata.map {
|
||||
@@ -339,7 +340,7 @@ fun EmojiCollectionGallery(emojiCollections: List<ATag>, accountViewModel: Accou
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item ->
|
||||
LoadAddressableNote(aTag = item) {
|
||||
LoadAddressableNote(aTag = item, accountViewModel) {
|
||||
it?.let {
|
||||
WatchAndRenderNote(it, bgColor, accountViewModel, nav, onClick)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
@@ -197,7 +198,11 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel) {
|
||||
fun UpdateZapAmountDialog(
|
||||
onClose: () -> Unit,
|
||||
nip47uri: String? = null,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -215,8 +220,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
|
||||
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
|
||||
)
|
||||
|
||||
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
|
||||
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
|
||||
val zapOptions = remember { zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
|
||||
LaunchedEffect(accountViewModel, nip47uri) {
|
||||
postViewModel.load()
|
||||
@@ -357,7 +361,6 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
|
||||
placeholder = zapTypes.filter { it.first == accountViewModel.defaultZapType() }
|
||||
.first().second,
|
||||
options = zapOptions,
|
||||
explainers = zapOptionExplainers,
|
||||
onSelect = {
|
||||
postViewModel.selectedZapType = zapTypes[it].first
|
||||
},
|
||||
|
||||
@@ -43,11 +43,13 @@ import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -102,6 +104,32 @@ fun DisplayBlankAuthor(size: Dp, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserPicture(
|
||||
userHex: String,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = remember { Modifier },
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadUser(baseUserHex = userHex, accountViewModel) {
|
||||
if (it != null) {
|
||||
UserPicture(
|
||||
user = it,
|
||||
size = size,
|
||||
pictureModifier = pictureModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
} else {
|
||||
DisplayBlankAuthor(
|
||||
size,
|
||||
pictureModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserPicture(
|
||||
user: User,
|
||||
@@ -188,57 +216,57 @@ fun NonClickableUserPictures(
|
||||
|
||||
when (userList.size) {
|
||||
0 -> {}
|
||||
1 -> LoadUser(baseUserHex = userList[0]) {
|
||||
1 -> LoadUser(baseUserHex = userList[0], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier)
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
LoadUser(baseUserHex = userList[0]) {
|
||||
LoadUser(baseUserHex = userList[0], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterStart))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[1]) {
|
||||
LoadUser(baseUserHex = userList[1], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterEnd))
|
||||
}
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
LoadUser(baseUserHex = userList[0]) {
|
||||
LoadUser(baseUserHex = userList[0], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[1]) {
|
||||
LoadUser(baseUserHex = userList[1], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[2]) {
|
||||
LoadUser(baseUserHex = userList[2], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LoadUser(baseUserHex = userList[0]) {
|
||||
LoadUser(baseUserHex = userList[0], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[1]) {
|
||||
LoadUser(baseUserHex = userList[1], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopStart))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[2]) {
|
||||
LoadUser(baseUserHex = userList[2], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[3]) {
|
||||
LoadUser(baseUserHex = userList[3], accountViewModel) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
@@ -383,11 +411,15 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (!state.isFollowingAuthor) {
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.follow(
|
||||
note.author ?: return@DropdownMenuItem
|
||||
); onDismiss()
|
||||
}) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
val author = note.author ?: return@DropdownMenuItem
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.follow(author)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.follow))
|
||||
}
|
||||
Divider()
|
||||
@@ -438,36 +470,104 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
|
||||
Text(stringResource(R.string.quick_action_share))
|
||||
}
|
||||
Divider()
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePrivateBookmark(note); onDismiss() } }) {
|
||||
Text(stringResource(R.string.remove_from_private_bookmarks))
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPrivateBookmark(note); onDismiss() } }) {
|
||||
Text(stringResource(R.string.add_to_private_bookmarks))
|
||||
}
|
||||
}
|
||||
if (state.isPublicBookmarkNote) {
|
||||
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePublicBookmark(note); onDismiss() } }) {
|
||||
Text(stringResource(R.string.remove_from_public_bookmarks))
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPublicBookmark(note); onDismiss() } }) {
|
||||
Text(stringResource(R.string.add_to_public_bookmarks))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.broadcast(note); onDismiss() } }) {
|
||||
Text(stringResource(R.string.broadcast))
|
||||
}
|
||||
Divider()
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) {
|
||||
Text(stringResource(R.string.request_deletion))
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
val bookmarks = accountViewModel.userProfile().latestBookmarkList
|
||||
ExternalSignerUtils.decrypt(
|
||||
bookmarks?.content ?: "",
|
||||
accountViewModel.account.keyPair.pubKey.toHexKey(),
|
||||
bookmarks?.id ?: ""
|
||||
)
|
||||
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
|
||||
accountViewModel.removePrivateBookmark(note, bookmarks?.decryptedContent ?: "")
|
||||
} else {
|
||||
accountViewModel.removePrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.remove_from_private_bookmarks))
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
|
||||
Text(stringResource(R.string.block_report))
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
val bookmarks = accountViewModel.userProfile().latestBookmarkList
|
||||
ExternalSignerUtils.decrypt(
|
||||
bookmarks?.content ?: "",
|
||||
accountViewModel.account.keyPair.pubKey.toHexKey(),
|
||||
bookmarks?.id ?: ""
|
||||
)
|
||||
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
|
||||
accountViewModel.addPrivateBookmark(note, bookmarks?.decryptedContent ?: "")
|
||||
} else {
|
||||
accountViewModel.addPrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.add_to_private_bookmarks))
|
||||
}
|
||||
}
|
||||
if (state.isPublicBookmarkNote) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
val bookmarks = accountViewModel.userProfile().latestBookmarkList
|
||||
ExternalSignerUtils.decrypt(
|
||||
bookmarks?.content ?: "",
|
||||
accountViewModel.account.keyPair.pubKey.toHexKey(),
|
||||
bookmarks?.id ?: ""
|
||||
)
|
||||
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
|
||||
accountViewModel.removePublicBookmark(
|
||||
note,
|
||||
bookmarks?.decryptedContent ?: ""
|
||||
)
|
||||
} else {
|
||||
accountViewModel.removePublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.remove_from_public_bookmarks))
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
val bookmarks = accountViewModel.userProfile().latestBookmarkList
|
||||
ExternalSignerUtils.decrypt(
|
||||
bookmarks?.content ?: "",
|
||||
accountViewModel.account.keyPair.pubKey.toHexKey(),
|
||||
bookmarks?.id ?: ""
|
||||
)
|
||||
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
|
||||
accountViewModel.addPublicBookmark(
|
||||
note,
|
||||
bookmarks?.decryptedContent ?: ""
|
||||
)
|
||||
} else {
|
||||
accountViewModel.addPublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.add_to_public_bookmarks))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
@@ -486,6 +586,16 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
|
||||
Text(stringResource(R.string.content_warning_see_warnings))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) {
|
||||
Text(stringResource(R.string.request_deletion))
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
|
||||
Text(stringResource(R.string.block_report))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reportDialogShowing) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -31,20 +32,20 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
@Composable
|
||||
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
|
||||
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true, textColor: Color = Color.Unspecified) {
|
||||
val authorState by baseNote.live().metadata.map {
|
||||
it.note.author
|
||||
}.observeAsState(baseNote.author)
|
||||
|
||||
Crossfade(targetState = authorState, modifier = weight) {
|
||||
it?.let {
|
||||
UsernameDisplay(it, weight, showPlayButton)
|
||||
UsernameDisplay(it, weight, showPlayButton, textColor = textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true, fontWeight: FontWeight = FontWeight.Bold) {
|
||||
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true, fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified) {
|
||||
val npubDisplay by remember {
|
||||
derivedStateOf {
|
||||
baseUser.pubkeyDisplayHex()
|
||||
@@ -57,9 +58,9 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton:
|
||||
|
||||
Crossfade(targetState = userMetadata, modifier = weight) {
|
||||
if (it != null) {
|
||||
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight)
|
||||
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight, textColor)
|
||||
} else {
|
||||
NPubDisplay(npubDisplay, weight, fontWeight)
|
||||
NPubDisplay(npubDisplay, weight, fontWeight, textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,27 +73,29 @@ private fun UserNameDisplay(
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
modifier: Modifier,
|
||||
showPlayButton: Boolean = true,
|
||||
fontWeight: FontWeight = FontWeight.Bold
|
||||
fontWeight: FontWeight = FontWeight.Bold,
|
||||
textColor: Color = Color.Unspecified
|
||||
) {
|
||||
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
|
||||
UserAndUsernameDisplay(bestDisplayName.trim(), tags, bestUserName.trim(), modifier, showPlayButton, fontWeight)
|
||||
UserAndUsernameDisplay(bestDisplayName.trim(), tags, bestUserName.trim(), modifier, showPlayButton, fontWeight, textColor)
|
||||
} else if (bestDisplayName != null) {
|
||||
UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight)
|
||||
UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight, textColor)
|
||||
} else if (bestUserName != null) {
|
||||
UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight)
|
||||
UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight, textColor)
|
||||
} else {
|
||||
NPubDisplay(npubDisplay, modifier, fontWeight)
|
||||
NPubDisplay(npubDisplay, modifier, fontWeight, textColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NPubDisplay(npubDisplay: String, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold) {
|
||||
fun NPubDisplay(npubDisplay: String, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified) {
|
||||
Text(
|
||||
text = npubDisplay,
|
||||
fontWeight = fontWeight,
|
||||
modifier = modifier,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,7 +105,8 @@ private fun UserDisplay(
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
modifier: Modifier,
|
||||
showPlayButton: Boolean = true,
|
||||
fontWeight: FontWeight = FontWeight.Bold
|
||||
fontWeight: FontWeight = FontWeight.Bold,
|
||||
textColor: Color = Color.Unspecified
|
||||
) {
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
CreateTextWithEmoji(
|
||||
@@ -111,7 +115,8 @@ private fun UserDisplay(
|
||||
fontWeight = fontWeight,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
color = textColor
|
||||
)
|
||||
if (showPlayButton) {
|
||||
Spacer(StdHorzSpacer)
|
||||
@@ -127,7 +132,8 @@ private fun UserAndUsernameDisplay(
|
||||
bestUserName: String,
|
||||
modifier: Modifier,
|
||||
showPlayButton: Boolean = true,
|
||||
fontWeight: FontWeight = FontWeight.Bold
|
||||
fontWeight: FontWeight = FontWeight.Bold,
|
||||
textColor: Color = Color.Unspecified
|
||||
) {
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
CreateTextWithEmoji(
|
||||
@@ -135,7 +141,8 @@ private fun UserAndUsernameDisplay(
|
||||
tags = tags,
|
||||
fontWeight = fontWeight,
|
||||
maxLines = 1,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
color = textColor
|
||||
)
|
||||
/*
|
||||
CreateTextWithEmoji(
|
||||
|
||||
@@ -1,38 +1,63 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.widget.Toast
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonColors
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Done
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ZapOptionstViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
@@ -61,17 +86,21 @@ class ZapOptionstViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, baseNote: Note) {
|
||||
fun ZapCustomDialog(
|
||||
onClose: () -> Unit,
|
||||
onError: (text: String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
baseNote: Note
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val postViewModel: ZapOptionstViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(accountViewModel) {
|
||||
postViewModel.load(accountViewModel.account)
|
||||
}
|
||||
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
|
||||
val zapTypes = listOf(
|
||||
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
|
||||
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
|
||||
@@ -79,8 +108,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
|
||||
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
|
||||
)
|
||||
|
||||
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
|
||||
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
|
||||
val zapOptions = remember { zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
var selectedZapType by remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) }
|
||||
|
||||
Dialog(
|
||||
@@ -111,20 +139,11 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
|
||||
null,
|
||||
postViewModel.customMessage.text,
|
||||
context,
|
||||
onError = {
|
||||
zappingProgress = 0f
|
||||
scope.launch {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
onPayViaIntent = onPayViaIntent,
|
||||
zapType = selectedZapType
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +181,6 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
|
||||
label = stringResource(id = R.string.zap_type),
|
||||
placeholder = zapTypes.filter { it.first == accountViewModel.account.defaultZapType }.first().second,
|
||||
options = zapOptions,
|
||||
explainers = zapOptionExplainers,
|
||||
onSelect = {
|
||||
selectedZapType = zapTypes[it].first
|
||||
},
|
||||
@@ -275,3 +293,115 @@ fun ErrorMessageDialog(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PayViaIntentDialog(
|
||||
payingInvoices: ImmutableList<ZapPaymentHandler.Payable>,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onPress = onClose)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
payingInvoices.forEachIndexed { index, it ->
|
||||
val paid = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
|
||||
if (it.user != null) {
|
||||
BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
DisplayBlankAuthor(size = Size55dp)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (it.user != null) {
|
||||
UsernameDisplay(it.user, showPlayButton = false)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(id = R.string.wallet_number, index + 1),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Row() {
|
||||
Text(
|
||||
text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Text(
|
||||
text = stringResource(id = R.string.sats),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
PayButton(isActive = !paid.value) {
|
||||
paid.value = true
|
||||
|
||||
val uri = "lightning:" + it.invoice
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PayButton(isActive: Boolean, modifier: Modifier = Modifier, onPost: () -> Unit = {}) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
onPost()
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
if (isActive) {
|
||||
Text(text = stringResource(R.string.pay), color = Color.White)
|
||||
} else {
|
||||
Text(text = stringResource(R.string.paid), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
|
||||
@@ -40,7 +39,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -53,16 +51,9 @@ fun ZapNoteCompose(baseReqResponse: ZapReqResponse, accountViewModel: AccountVie
|
||||
}
|
||||
|
||||
LaunchedEffect(baseNoteRequest) {
|
||||
launch(Dispatchers.Default) {
|
||||
(baseNoteRequest?.note?.event as? LnZapRequestEvent)?.let {
|
||||
baseNoteRequest?.note?.let {
|
||||
val decryptedContent = accountViewModel.decryptZap(it)
|
||||
if (decryptedContent != null) {
|
||||
baseAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
} else {
|
||||
baseAuthor = it.author
|
||||
}
|
||||
}
|
||||
baseNoteRequest?.note?.let {
|
||||
accountViewModel.decryptAmountMessage(it, baseReqResponse.zapEvent) {
|
||||
baseAuthor = it?.user
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +164,8 @@ fun UserActionOptions(
|
||||
baseAuthor: User,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchIsHiddenUser(baseAuthor, accountViewModel) { isHidden ->
|
||||
if (isHidden) {
|
||||
ShowUserButton {
|
||||
@@ -209,14 +202,20 @@ fun ShowFollowingOrUnfollowingButton(
|
||||
if (isFollowing) {
|
||||
UnfollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.unfollow(baseAuthor)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -227,14 +226,20 @@ fun ShowFollowingOrUnfollowingButton(
|
||||
} else {
|
||||
FollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.follow(baseAuthor)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -5,13 +5,14 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -24,8 +25,10 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -84,11 +87,33 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||
val zapEvents by remember { derivedStateOf { zapSetCard.zapEvents } }
|
||||
AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel)
|
||||
Column(modifier = Modifier) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
MapZaps(zapSetCard.zapEvents, accountViewModel) {
|
||||
AuthorGalleryZaps(it, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, nav = nav)
|
||||
Spacer(DoubleVertSpacer)
|
||||
|
||||
Row(Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
UserPicture(
|
||||
zapSetCard.user,
|
||||
Size55dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UsernameDisplay(zapSetCard.user)
|
||||
}
|
||||
|
||||
AboutDisplay(zapSetCard.user)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,21 +45,21 @@ class AccountStateViewModel(val context: Context) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun startUI(key: String, useProxy: Boolean, proxyPort: Int) {
|
||||
fun startUI(key: String, useProxy: Boolean, proxyPort: Int, loginWithExternalSigner: Boolean = false) {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val account =
|
||||
if (key.startsWith("nsec")) {
|
||||
Account(KeyPair(privKey = key.bechToBytes()), proxy = proxy, proxyPort = proxyPort)
|
||||
Account(KeyPair(privKey = key.bechToBytes()), proxy = proxy, proxyPort = proxyPort, loginWithExternalSigner = loginWithExternalSigner)
|
||||
} else if (pubKeyParsed != null) {
|
||||
Account(KeyPair(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort)
|
||||
Account(KeyPair(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort, loginWithExternalSigner = loginWithExternalSigner)
|
||||
} else if (EMAIL_PATTERN.matcher(key).matches()) {
|
||||
// Evaluate NIP-5
|
||||
Account(KeyPair(), proxy = proxy, proxyPort = proxyPort)
|
||||
Account(KeyPair(), proxy = proxy, proxyPort = proxyPort, loginWithExternalSigner = loginWithExternalSigner)
|
||||
} else {
|
||||
Account(KeyPair(Hex.decode(key)), proxy = proxy, proxyPort = proxyPort)
|
||||
Account(KeyPair(Hex.decode(key)), proxy = proxy, proxyPort = proxyPort, loginWithExternalSigner = loginWithExternalSigner)
|
||||
}
|
||||
|
||||
LocalPreferences.updatePrefsForLogin(account)
|
||||
|
||||
@@ -34,6 +34,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@Stable
|
||||
@@ -173,6 +176,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
|
||||
|
||||
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys
|
||||
val multiCards = allBaseNotes.map { baseNote ->
|
||||
val boostsInCard = boostsPerEvent[baseNote] ?: emptyList()
|
||||
@@ -180,26 +185,47 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
val zapsInCard = zapsPerEvent[baseNote] ?: emptyList()
|
||||
|
||||
val singleList = (boostsInCard + zapsInCard.map { it.response } + reactionsInCard)
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
.groupBy {
|
||||
sdf.format(
|
||||
Instant.ofEpochSecond(it.createdAt() ?: 0)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
singleList.chunked(30).map { chunk ->
|
||||
MultiSetCard(
|
||||
baseNote,
|
||||
boostsInCard.filter { it in chunk }.toImmutableList(),
|
||||
reactionsInCard.filter { it in chunk }.toImmutableList(),
|
||||
zapsInCard.filter { it.response in chunk }.toImmutableList()
|
||||
val days = singleList.keys.sortedBy { it }
|
||||
|
||||
days.mapNotNull {
|
||||
val sortedList = singleList.get(it)?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))?.reversed()
|
||||
|
||||
sortedList?.chunked(30)?.map { chunk ->
|
||||
MultiSetCard(
|
||||
baseNote,
|
||||
boostsInCard.filter { it in chunk }.toImmutableList(),
|
||||
reactionsInCard.filter { it in chunk }.toImmutableList(),
|
||||
zapsInCard.filter { it.response in chunk }.toImmutableList()
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}.flatten()
|
||||
|
||||
val userZaps = zapsPerUser.map { user ->
|
||||
val byDay = user.value.groupBy {
|
||||
sdf.format(
|
||||
Instant.ofEpochSecond(it.createdAt() ?: 0)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
byDay.values.map {
|
||||
ZapUserSetCard(
|
||||
user.key,
|
||||
it.sortedWith(compareBy({ it.createdAt() }, { it.idHex() })).reversed().toImmutableList()
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
|
||||
val userZaps = zapsPerUser.map {
|
||||
ZapUserSetCard(
|
||||
it.key,
|
||||
it.value.toImmutableList()
|
||||
)
|
||||
}
|
||||
|
||||
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is GenericRepostEvent && it.event !is LnZapEvent }.map {
|
||||
if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) {
|
||||
MessageSetCard(it)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
|
||||
@Stable
|
||||
class RelayPoolViewModel : ViewModel() {
|
||||
val connectionStatus = RelayPool.live.map {
|
||||
val connectedRelays = it.relays.connectedRelays()
|
||||
val availableRelays = it.relays.availableRelays()
|
||||
"$connectedRelays/$availableRelays"
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val isConnected = RelayPool.live.map {
|
||||
it.relays.connectedRelays() > 0
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.note.*
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -146,7 +147,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item ->
|
||||
if (index == 0) {
|
||||
ProvideTextStyle(TextStyle(fontSize = 16.sp, lineHeight = 1.20.em)) {
|
||||
ProvideTextStyle(TextStyle(fontSize = 18.sp, lineHeight = 1.20.em)) {
|
||||
NoteMaster(
|
||||
item,
|
||||
modifier = Modifier.drawReplyLevel(
|
||||
@@ -463,6 +464,13 @@ fun NoteMaster(
|
||||
}
|
||||
}
|
||||
|
||||
val noteEvent = baseNote.event
|
||||
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
|
||||
if (zapSplits && noteEvent != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
DisplayZapSplits(noteEvent, accountViewModel, nav)
|
||||
}
|
||||
|
||||
ReactionsRow(note, true, accountViewModel, nav)
|
||||
|
||||
Divider(
|
||||
@@ -589,20 +597,20 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) {
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
noteEvent.title()?.let {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
noteEvent.summary()?.let {
|
||||
noteEvent.summary()?.ifBlank { null }?.let {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -101,10 +101,12 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
|
||||
private val bundler = BundledUpdate(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+201
-77
@@ -1,55 +1,60 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountState
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.Nip05Verifier
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.actions.Dao
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.events.Participant
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
|
||||
@Stable
|
||||
class AccountViewModel(val account: Account) : ViewModel() {
|
||||
class AccountViewModel(val account: Account) : ViewModel(), Dao {
|
||||
val accountLiveData: LiveData<AccountState> = account.live.map { it }
|
||||
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
|
||||
val accountLastReadLiveData: LiveData<AccountState> = account.liveLastRead.map { it }
|
||||
@@ -99,6 +104,10 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
return account.isWriteable()
|
||||
}
|
||||
|
||||
fun loggedInWithExternalSigner(): Boolean {
|
||||
return account.loginWithExternalSigner
|
||||
}
|
||||
|
||||
fun userProfile(): User {
|
||||
return account.userProfile()
|
||||
}
|
||||
@@ -173,6 +182,38 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptAmountMessageInGroup(
|
||||
zaps: ImmutableList<CombinedZap>,
|
||||
onNewState: (ImmutableList<ZapAmountCommentNotification>) -> Unit
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val list = ArrayList<ZapAmountCommentNotification>(zaps.size)
|
||||
zaps.forEach {
|
||||
innerDecryptAmountMessage(it.request, it.response)?.let {
|
||||
list.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
onNewState(list.toImmutableList())
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptAmountMessageInGroup(
|
||||
baseNote: Note,
|
||||
onNewState: (ImmutableList<ZapAmountCommentNotification>) -> Unit
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val list = ArrayList<ZapAmountCommentNotification>(baseNote.zaps.size)
|
||||
baseNote.zaps.forEach {
|
||||
innerDecryptAmountMessage(it.key, it.value)?.let {
|
||||
list.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
onNewState(list.toImmutableList())
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptAmountMessage(
|
||||
zapRequest: Note,
|
||||
zapEvent: Note?,
|
||||
@@ -187,6 +228,8 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
zapRequest: Note,
|
||||
zapEvent: Note?
|
||||
): ZapAmountCommentNotification? {
|
||||
checkNotInMainThread()
|
||||
|
||||
(zapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = decryptZap(zapRequest)
|
||||
val amount = (zapEvent?.event as? LnZapEvent)?.amount
|
||||
@@ -210,78 +253,22 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
return null
|
||||
}
|
||||
|
||||
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
|
||||
fun zap(
|
||||
note: Note,
|
||||
amount: Long,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
context: Context,
|
||||
onError: (String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||
zapType: LnZapEvent.ZapType
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
innerZap(note, amount, pollOption, message, context, onError, onProgress, zapType)
|
||||
ZapPaymentHandler(account).zap(note, amount, pollOption, message, context, onError, onProgress, onPayViaIntent, zapType)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
|
||||
val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
|
||||
return
|
||||
}
|
||||
|
||||
var zapRequestJson = ""
|
||||
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP) {
|
||||
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType)
|
||||
if (zapRequest != null) {
|
||||
zapRequestJson = zapRequest.toJson()
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(0.10f)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount,
|
||||
message,
|
||||
zapRequestJson,
|
||||
onSuccess = {
|
||||
onProgress(0.7f)
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = it,
|
||||
note,
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString()
|
||||
?: "Error parsing error message"
|
||||
)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
}
|
||||
}
|
||||
)
|
||||
onProgress(0.8f)
|
||||
|
||||
// Awaits for the event to come back to LocalCache.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(5000)
|
||||
onProgress(0f)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
} catch (e: Exception) {
|
||||
onError(context.getString(R.string.lightning_wallets_not_found))
|
||||
}
|
||||
onProgress(0f)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = onProgress
|
||||
)
|
||||
}
|
||||
|
||||
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
|
||||
account.report(note, type, content)
|
||||
}
|
||||
@@ -309,10 +296,26 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
account.addPrivateBookmark(note)
|
||||
}
|
||||
|
||||
fun addPrivateBookmark(note: Note, decryptedContent: String) {
|
||||
account.addPrivateBookmark(note, decryptedContent)
|
||||
}
|
||||
|
||||
fun addPublicBookmark(note: Note, decryptedContent: String) {
|
||||
account.addPublicBookmark(note, decryptedContent)
|
||||
}
|
||||
|
||||
fun removePublicBookmark(note: Note, decryptedContent: String) {
|
||||
account.removePublicBookmark(note, decryptedContent)
|
||||
}
|
||||
|
||||
fun addPublicBookmark(note: Note) {
|
||||
account.addPublicBookmark(note)
|
||||
}
|
||||
|
||||
fun removePrivateBookmark(note: Note, decryptedContent: String) {
|
||||
account.removePrivateBookmark(note, decryptedContent)
|
||||
}
|
||||
|
||||
fun removePrivateBookmark(note: Note) {
|
||||
account.removePrivateBookmark(note)
|
||||
}
|
||||
@@ -424,7 +427,7 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
)
|
||||
|
||||
fun isNoteAcceptable(note: Note, onReady: (NoteComposeReportState) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
|
||||
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
|
||||
|
||||
@@ -551,6 +554,127 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun retrieveRelayDocument(
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
Nip11CachedRetriever.loadRelayInfo(dirtyUrl, onInfo, onError)
|
||||
}
|
||||
}
|
||||
|
||||
fun runOnIO(runOnIO: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runOnIO()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkGetOrCreateUser(key: HexKey): User? {
|
||||
return LocalCache.checkGetOrCreateUser(key)
|
||||
}
|
||||
|
||||
override suspend fun getOrCreateUser(key: HexKey): User {
|
||||
return LocalCache.getOrCreateUser(key)
|
||||
}
|
||||
|
||||
fun checkGetOrCreateUser(key: HexKey, onResult: (User?) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onResult(checkGetOrCreateUser(key))
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserIfExists(hex: HexKey): User? {
|
||||
return LocalCache.getUserIfExists(hex)
|
||||
}
|
||||
|
||||
suspend fun checkGetOrCreateNote(key: HexKey): Note? {
|
||||
return LocalCache.checkGetOrCreateNote(key)
|
||||
}
|
||||
|
||||
override suspend fun getOrCreateNote(key: HexKey): Note {
|
||||
return LocalCache.getOrCreateNote(key)
|
||||
}
|
||||
|
||||
fun checkGetOrCreateNote(key: HexKey, onResult: (Note?) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onResult(checkGetOrCreateNote(key))
|
||||
}
|
||||
}
|
||||
|
||||
fun getNoteIfExists(hex: HexKey): Note? {
|
||||
return LocalCache.getNoteIfExists(hex)
|
||||
}
|
||||
|
||||
override suspend fun checkGetOrCreateAddressableNote(key: HexKey): AddressableNote? {
|
||||
return LocalCache.checkGetOrCreateAddressableNote(key)
|
||||
}
|
||||
|
||||
fun checkGetOrCreateAddressableNote(key: HexKey, onResult: (AddressableNote?) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onResult(checkGetOrCreateAddressableNote(key))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? {
|
||||
return LocalCache.getOrCreateAddressableNote(key)
|
||||
}
|
||||
|
||||
fun getOrCreateAddressableNote(key: ATag, onResult: (AddressableNote?) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onResult(getOrCreateAddressableNote(key))
|
||||
}
|
||||
}
|
||||
|
||||
fun getAddressableNoteIfExists(key: String): AddressableNote? {
|
||||
return LocalCache.addressables[key]
|
||||
}
|
||||
|
||||
fun findStatusesForUser(myUser: User, onResult: (ImmutableList<AddressableNote>) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onResult(LocalCache.findStatusesForUser(myUser))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
|
||||
return LocalCache.checkGetOrCreateChannel(key)
|
||||
}
|
||||
|
||||
fun checkGetOrCreateChannel(key: HexKey, onResult: (Channel?) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onResult(checkGetOrCreateChannel(key))
|
||||
}
|
||||
}
|
||||
|
||||
fun getChannelIfExists(hex: HexKey): Channel? {
|
||||
return LocalCache.getChannelIfExists(hex)
|
||||
}
|
||||
|
||||
fun loadParticipants(participants: List<Participant>, onReady: (ImmutableList<Pair<Participant, User>>) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val participantUsers = participants.mapNotNull { part ->
|
||||
checkGetOrCreateUser(part.key)?.let {
|
||||
Pair(
|
||||
part,
|
||||
it
|
||||
)
|
||||
}
|
||||
}.toImmutableList()
|
||||
|
||||
onReady(participantUsers)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUsers(hexList: List<String>, onReady: (ImmutableList<User>) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onReady(
|
||||
hexList.mapNotNull { hex ->
|
||||
checkGetOrCreateUser(hex)
|
||||
}.sortedBy { account.isFollowing(it) }.reversed().toImmutableList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
|
||||
return AccountViewModel(account) as AccountViewModel
|
||||
|
||||
@@ -129,7 +129,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.quartz.events.Participant
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
@@ -149,7 +149,7 @@ fun ChannelScreen(
|
||||
) {
|
||||
if (channelId == null) return
|
||||
|
||||
LoadChannel(channelId) {
|
||||
LoadChannel(channelId, accountViewModel) {
|
||||
PrepareChannelViewModels(
|
||||
baseChannel = it,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -169,6 +169,7 @@ fun PrepareChannelViewModels(baseChannel: Channel, accountViewModel: AccountView
|
||||
)
|
||||
|
||||
val channelScreenModel: NewPostViewModel = viewModel()
|
||||
channelScreenModel.accountViewModel = accountViewModel
|
||||
channelScreenModel.account = accountViewModel.account
|
||||
|
||||
ChannelScreen(
|
||||
@@ -267,7 +268,8 @@ fun ChannelScreen(
|
||||
message = newPostModel.message.text,
|
||||
mentions = listOfNotNull(replyTo.value?.author),
|
||||
replyTos = listOfNotNull(replyTo.value),
|
||||
channelHex = channel.idHex
|
||||
channelHex = channel.idHex,
|
||||
dao = accountViewModel
|
||||
)
|
||||
tagger.run()
|
||||
if (channel is PublicChatChannel) {
|
||||
@@ -530,7 +532,7 @@ fun ChannelHeader(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadChannel(channelHex) {
|
||||
LoadChannel(channelHex, accountViewModel) {
|
||||
ChannelHeader(
|
||||
it,
|
||||
showVideo,
|
||||
@@ -749,9 +751,9 @@ fun LongChannelHeader(
|
||||
|
||||
val tags = remember(channelState) {
|
||||
if (baseChannel is LiveActivitiesChannel) {
|
||||
baseChannel.info?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
|
||||
baseChannel.info?.tags()?.toImmutableListOfLists() ?: EmptyTagList
|
||||
} else {
|
||||
ImmutableListOfLists()
|
||||
EmptyTagList
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,7 +785,7 @@ fun LongChannelHeader(
|
||||
}
|
||||
}
|
||||
|
||||
LoadNote(baseNoteHex = channel.idHex) { loadingNote ->
|
||||
LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote ->
|
||||
loadingNote?.let { note ->
|
||||
Row(
|
||||
lineModifier,
|
||||
@@ -886,7 +888,7 @@ private fun ShortChannelActionOptions(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadNote(baseNoteHex = channel.idHex) {
|
||||
LoadNote(baseNoteHex = channel.idHex, accountViewModel) {
|
||||
it?.let {
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ fun PrepareChatroomViewModels(
|
||||
)
|
||||
|
||||
val newPostModel: NewPostViewModel = viewModel()
|
||||
newPostModel.accountViewModel = accountViewModel
|
||||
newPostModel.account = accountViewModel.account
|
||||
newPostModel.requiresNIP24 = room.users.size > 1
|
||||
if (newPostModel.requiresNIP24) {
|
||||
@@ -482,7 +483,7 @@ fun ChatroomHeader(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
if (room.users.size == 1) {
|
||||
LoadUser(baseUserHex = room.users.first()) { baseUser ->
|
||||
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
|
||||
if (baseUser != null) {
|
||||
ChatroomHeader(baseUser = baseUser, modifier = modifier, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
@@ -559,7 +560,7 @@ fun GroupChatroomHeader(
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
RoomNameOnlyDisplay(room, Modifier, FontWeight.Bold, accountViewModel.userProfile())
|
||||
DisplayUserSetAsSubject(room, FontWeight.Normal)
|
||||
DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,7 +734,7 @@ fun LongRoomHeader(
|
||||
state = rememberLazyListState()
|
||||
) {
|
||||
itemsIndexed(list, key = { _, item -> item }) { _, item ->
|
||||
LoadUser(baseUserHex = item) {
|
||||
LoadUser(baseUserHex = item, accountViewModel) {
|
||||
if (it != null) {
|
||||
UserCompose(
|
||||
baseUser = it,
|
||||
|
||||
@@ -22,7 +22,7 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
||||
fun CommunityScreen(aTagHex: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
if (aTagHex == null) return
|
||||
|
||||
LoadAddressableNote(aTagHex = aTagHex) {
|
||||
LoadAddressableNote(aTagHex = aTagHex, accountViewModel) {
|
||||
it?.let {
|
||||
PrepareViewModelsCommunityScreen(
|
||||
note = it,
|
||||
|
||||
@@ -171,14 +171,20 @@ fun GeoHashActionOptions(
|
||||
if (isFollowingTag) {
|
||||
UnfollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.unfollowGeohash(tag)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -189,14 +195,20 @@ fun GeoHashActionOptions(
|
||||
} else {
|
||||
FollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.followGeohash(tag)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -147,14 +147,20 @@ fun HashtagActionOptions(
|
||||
if (isFollowingTag) {
|
||||
UnfollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.unfollowHashtag(tag)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -165,14 +171,20 @@ fun HashtagActionOptions(
|
||||
} else {
|
||||
FollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.followHashtag(tag)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -9,15 +9,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.DrawerValue
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ModalBottomSheetLayout
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.rememberDrawerState
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
@@ -28,6 +19,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
@@ -37,12 +29,6 @@ import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
|
||||
import com.vitorpamplona.amethyst.ui.buttons.NewImageButton
|
||||
import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.*
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppTopBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.DrawerContent
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
@@ -251,11 +237,14 @@ fun FloatingButtons(
|
||||
navScrollToTop: (Route, Boolean) -> Unit
|
||||
) {
|
||||
val accountState by accountStateViewModel.accountContent.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
// Does nothing.
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop)
|
||||
}
|
||||
}
|
||||
is AccountState.LoggedOff -> {
|
||||
// Does nothing.
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -97,9 +98,9 @@ import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.GitHubIdentity
|
||||
import com.vitorpamplona.quartz.events.IdentityClaim
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.MastodonIdentity
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
|
||||
@@ -118,7 +119,7 @@ import java.math.BigDecimal
|
||||
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
if (userId == null) return
|
||||
|
||||
var userBase by remember { mutableStateOf<User?>(LocalCache.getUserIfExists(userId)) }
|
||||
var userBase by remember { mutableStateOf(LocalCache.getUserIfExists(userId)) }
|
||||
|
||||
if (userBase == null) {
|
||||
LaunchedEffect(userId) {
|
||||
@@ -280,7 +281,6 @@ fun ProfileScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun RenderSurface(
|
||||
baseUser: User,
|
||||
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
|
||||
@@ -383,7 +383,7 @@ private fun RenderScreen(
|
||||
) {
|
||||
val pagerState = rememberPagerState { 9 }
|
||||
|
||||
Column() {
|
||||
Column {
|
||||
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
|
||||
ScrollableTabRow(
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
@@ -449,7 +449,7 @@ private fun CreateAndRenderTabs(
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val tabs = listOf<@Composable() (() -> Unit)?>(
|
||||
val tabs = listOf<@Composable (() -> Unit)?>(
|
||||
{ Text(text = stringResource(R.string.notes)) },
|
||||
{ Text(text = stringResource(R.string.replies)) },
|
||||
{ FollowTabHeader(baseUser) },
|
||||
@@ -486,7 +486,7 @@ private fun RelaysTabHeader(baseUser: User) {
|
||||
@Composable
|
||||
private fun ReportsTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().reports.observeAsState()
|
||||
var userReports by remember { mutableStateOf(0) }
|
||||
var userReports by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(key1 = userState) {
|
||||
launch(Dispatchers.IO) {
|
||||
@@ -503,11 +503,11 @@ private fun ReportsTabHeader(baseUser: User) {
|
||||
|
||||
@Composable
|
||||
private fun FollowedTagsTabHeader(baseUser: User) {
|
||||
var usertags by remember { mutableStateOf(0) }
|
||||
var usertags by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(key1 = baseUser) {
|
||||
launch(Dispatchers.IO) {
|
||||
val contactList = baseUser?.latestContactList
|
||||
val contactList = baseUser.latestContactList
|
||||
|
||||
val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0)
|
||||
|
||||
@@ -524,7 +524,7 @@ private fun FollowedTagsTabHeader(baseUser: User) {
|
||||
private fun BookmarkTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().bookmarks.observeAsState()
|
||||
|
||||
var userBookmarks by remember { mutableStateOf(0) }
|
||||
var userBookmarks by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(key1 = userState) {
|
||||
launch(Dispatchers.IO) {
|
||||
@@ -621,7 +621,9 @@ private fun ProfileHeader(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.align(Alignment.Center),
|
||||
onClick = { popupExpanded = true },
|
||||
onClick = {
|
||||
popupExpanded = true
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
@@ -744,14 +746,20 @@ private fun DisplayFollowUnfollowButton(
|
||||
if (isLoggedInFollowingUser) {
|
||||
UnfollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.unfollow(baseUser)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -763,14 +771,20 @@ private fun DisplayFollowUnfollowButton(
|
||||
if (isUserFollowingLoggedIn) {
|
||||
FollowButton(R.string.follow_back) {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.follow(baseUser)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -781,14 +795,20 @@ private fun DisplayFollowUnfollowButton(
|
||||
} else {
|
||||
FollowButton(R.string.follow) {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.follow(baseUser)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -918,7 +938,7 @@ private fun DrawAdditionalInfo(
|
||||
}
|
||||
}
|
||||
|
||||
DisplayBadges(baseUser, nav)
|
||||
DisplayBadges(baseUser, accountViewModel, nav)
|
||||
|
||||
DisplayNip05ProfileStatus(user, accountViewModel)
|
||||
|
||||
@@ -980,7 +1000,7 @@ private fun DrawAdditionalInfo(
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = false,
|
||||
tags = remember { ImmutableListOfLists<String>(emptyList()) },
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = background,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
@@ -1092,7 +1112,7 @@ private fun DisplayAppRecommendations(
|
||||
) { state ->
|
||||
when (state) {
|
||||
is FeedState.Loaded -> {
|
||||
Column() {
|
||||
Column {
|
||||
Text(stringResource(id = R.string.recommended_apps))
|
||||
|
||||
FlowRow(
|
||||
@@ -1115,7 +1135,7 @@ private fun DisplayAppRecommendations(
|
||||
private fun WatchApp(baseApp: Note, nav: (String) -> Unit) {
|
||||
val appState by baseApp.live().metadata.observeAsState()
|
||||
|
||||
var appLogo by remember { mutableStateOf<String?>(null) }
|
||||
var appLogo by remember(baseApp) { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = appState) {
|
||||
launch(Dispatchers.Default) {
|
||||
@@ -1152,6 +1172,7 @@ private fun WatchApp(baseApp: Note, nav: (String) -> Unit) {
|
||||
@Composable
|
||||
private fun DisplayBadges(
|
||||
baseUser: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadAddressableNote(
|
||||
@@ -1160,20 +1181,29 @@ private fun DisplayBadges(
|
||||
baseUser.pubkeyHex,
|
||||
BadgeProfilesEvent.standardDTAg,
|
||||
null
|
||||
)
|
||||
) {
|
||||
if (it != null) {
|
||||
val badgeList by it.live().metadata.map {
|
||||
(it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList()
|
||||
}.distinctUntilChanged().observeAsState()
|
||||
|
||||
badgeList?.let { list ->
|
||||
RenderBadgeList(list, nav)
|
||||
}
|
||||
),
|
||||
accountViewModel
|
||||
) { note ->
|
||||
if (note != null) {
|
||||
WatchAndRenderBadgeList(note, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchAndRenderBadgeList(
|
||||
note: AddressableNote,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val badgeList by note.live().metadata.map {
|
||||
(it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList()
|
||||
}.distinctUntilChanged().observeAsState()
|
||||
|
||||
badgeList?.let { list ->
|
||||
RenderBadgeList(list, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun RenderBadgeList(
|
||||
@@ -1193,7 +1223,7 @@ private fun RenderBadgeList(
|
||||
@Composable
|
||||
private fun LoadAndRenderBadge(badgeAwardEventHex: String, nav: (String) -> Unit) {
|
||||
var baseNote by remember {
|
||||
mutableStateOf<Note?>(LocalCache.getNoteIfExists(badgeAwardEventHex))
|
||||
mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEventHex))
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = badgeAwardEventHex) {
|
||||
@@ -1438,7 +1468,7 @@ fun TabFollows(baseUser: User, feedViewModel: UserFeedViewModel, accountViewMode
|
||||
WatchFollowChanges(baseUser, feedViewModel)
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column() {
|
||||
Column {
|
||||
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
|
||||
}
|
||||
}
|
||||
@@ -1449,7 +1479,7 @@ fun TabFollowers(baseUser: User, feedViewModel: UserFeedViewModel, accountViewMo
|
||||
WatchFollowerChanges(baseUser, feedViewModel)
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column() {
|
||||
Column {
|
||||
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
|
||||
}
|
||||
}
|
||||
@@ -1484,7 +1514,7 @@ fun TabReceivedZaps(baseUser: User, zapFeedViewModel: NostrUserProfileZapsFeedVi
|
||||
WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel)
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column() {
|
||||
Column {
|
||||
LnZapFeedView(zapFeedViewModel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
@@ -1507,7 +1537,7 @@ fun TabReports(baseUser: User, feedViewModel: NostrUserProfileReportFeedViewMode
|
||||
WatchReportsAndUpdateFeed(baseUser, feedViewModel)
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column() {
|
||||
Column {
|
||||
RefresheableFeedView(
|
||||
feedViewModel,
|
||||
null,
|
||||
@@ -1679,7 +1709,6 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); onDismiss() }) {
|
||||
Text(stringResource(R.string.copy_user_id))
|
||||
@@ -1688,17 +1717,21 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
|
||||
if (accountViewModel.userProfile() != user) {
|
||||
Divider()
|
||||
if (accountViewModel.account.isHidden(user)) {
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.show(user)
|
||||
onDismiss()
|
||||
}) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
accountViewModel.show(user)
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.unblock_user))
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.hide(user)
|
||||
onDismiss()
|
||||
}) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
accountViewModel.hide(user)
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(id = R.string.block_hide_user))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss:
|
||||
Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal))
|
||||
)
|
||||
|
||||
val reasonOptions = remember { reportTypes.map { it.second }.toImmutableList() }
|
||||
val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() }
|
||||
var additionalReason by remember { mutableStateOf("") }
|
||||
var selectedReason by remember { mutableStateOf(-1) }
|
||||
|
||||
|
||||
@@ -111,15 +111,15 @@ fun SettingsScreen(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val selectedItens = persistentListOf(
|
||||
stringResource(ConnectivityType.ALWAYS.reourceId),
|
||||
stringResource(ConnectivityType.WIFI_ONLY.reourceId),
|
||||
stringResource(ConnectivityType.NEVER.reourceId)
|
||||
TitleExplainer(stringResource(ConnectivityType.ALWAYS.reourceId)),
|
||||
TitleExplainer(stringResource(ConnectivityType.WIFI_ONLY.reourceId)),
|
||||
TitleExplainer(stringResource(ConnectivityType.NEVER.reourceId))
|
||||
)
|
||||
|
||||
val themeItens = persistentListOf(
|
||||
stringResource(R.string.system),
|
||||
stringResource(R.string.light),
|
||||
stringResource(R.string.dark)
|
||||
TitleExplainer(stringResource(R.string.system)),
|
||||
TitleExplainer(stringResource(R.string.light)),
|
||||
TitleExplainer(stringResource(R.string.dark))
|
||||
)
|
||||
|
||||
val settings = accountViewModel.account.settings
|
||||
@@ -132,7 +132,7 @@ fun SettingsScreen(
|
||||
val context = LocalContext.current
|
||||
|
||||
val languageEntries = context.getLangPreferenceDropdownEntries()
|
||||
val languageList = languageEntries.keys.toImmutableList()
|
||||
val languageList = languageEntries.keys.map { TitleExplainer(it) }.toImmutableList()
|
||||
val languageIndex = getLanguageIndex(languageEntries)
|
||||
|
||||
Column(
|
||||
@@ -149,12 +149,12 @@ fun SettingsScreen(
|
||||
) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
val job = scope.launch(Dispatchers.IO) {
|
||||
val locale = languageEntries[languageList[it]]
|
||||
val locale = languageEntries[languageList[it].title]
|
||||
accountViewModel.account.settings.preferredLanguage = locale
|
||||
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
|
||||
}
|
||||
job.join()
|
||||
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(languageEntries[languageList[it]])
|
||||
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(languageEntries[languageList[it].title])
|
||||
AppCompatDelegate.setApplicationLocales(appLocale)
|
||||
}
|
||||
}
|
||||
@@ -227,7 +227,7 @@ fun SettingsScreen(
|
||||
fun SettingsRow(
|
||||
name: Int,
|
||||
description: Int,
|
||||
selectedItens: ImmutableList<String>,
|
||||
selectedItens: ImmutableList<TitleExplainer>,
|
||||
selectedIndex: Int,
|
||||
onSelect: (Int) -> Unit
|
||||
) {
|
||||
@@ -255,7 +255,7 @@ fun SettingsRow(
|
||||
|
||||
TextSpinner(
|
||||
label = "",
|
||||
placeholder = selectedItens[selectedIndex],
|
||||
placeholder = selectedItens[selectedIndex].title,
|
||||
options = selectedItens,
|
||||
onSelect = onSelect,
|
||||
modifier = Modifier
|
||||
|
||||
@@ -127,7 +127,6 @@ fun WatchAccountForVideoScreen(videoFeedView: NostrVideoFeedViewModel, accountVi
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SaveableFeedState(
|
||||
videoFeedView: NostrVideoFeedViewModel,
|
||||
@@ -416,14 +415,11 @@ private fun VideoUserOptionAction(
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||
val noteRelays = remember(noteRelaysState) {
|
||||
noteRelaysState?.note?.relays ?: emptySet()
|
||||
}
|
||||
val noteRelays by baseNote.live().relayInfo.observeAsState()
|
||||
|
||||
FlowRow() {
|
||||
noteRelays.forEach { dirtyUrl ->
|
||||
RenderRelay(dirtyUrl, accountViewModel, nav)
|
||||
noteRelays?.forEach { relayInfo ->
|
||||
RenderRelay(relayInfo, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedOff
|
||||
|
||||
import android.content.Context
|
||||
import android.app.Activity
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -21,7 +24,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillNode
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
@@ -36,13 +38,20 @@ import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.PackageUtils
|
||||
import com.vitorpamplona.amethyst.service.SignerType
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@@ -63,6 +72,58 @@ fun LoginPage(
|
||||
val useProxy = remember { mutableStateOf(false) }
|
||||
val proxyPort = remember { mutableStateOf("9050") }
|
||||
var connectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var loginWithExternalSigner by remember { mutableStateOf(false) }
|
||||
val activity = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
onResult = {
|
||||
loginWithExternalSigner = false
|
||||
ExternalSignerUtils.isActivityRunning = false
|
||||
ServiceManager.shouldPauseService = true
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
Amethyst.instance,
|
||||
"Sign request rejected",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
return@rememberLauncherForActivityResult
|
||||
} else {
|
||||
val event = it.data?.getStringExtra("signature") ?: ""
|
||||
key.value = TextFieldValue(event)
|
||||
if (!acceptedTerms.value) {
|
||||
termsAcceptanceIsRequired =
|
||||
context.getString(R.string.acceptance_of_terms_is_required)
|
||||
}
|
||||
|
||||
if (key.value.text.isBlank()) {
|
||||
errorMessage = context.getString(R.string.key_is_required)
|
||||
}
|
||||
|
||||
if (acceptedTerms.value && key.value.text.isNotBlank()) {
|
||||
try {
|
||||
accountViewModel.startUI(key.value.text, useProxy.value, proxyPort.value.toInt(), true)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Login", "Could not sign in", e)
|
||||
errorMessage = context.getString(R.string.invalid_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(loginWithExternalSigner) {
|
||||
if (loginWithExternalSigner) {
|
||||
ExternalSignerUtils.openSigner(
|
||||
"",
|
||||
SignerType.GET_PUBLIC_KEY,
|
||||
activity,
|
||||
"",
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -229,7 +290,7 @@ fun LoginPage(
|
||||
}
|
||||
}
|
||||
|
||||
if (isPackageInstalled(context, "org.torproject.android")) {
|
||||
if (PackageUtils.isOrbotInstalled(context)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = useProxy.value,
|
||||
@@ -259,10 +320,10 @@ fun LoginPage(
|
||||
|
||||
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
|
||||
Button(
|
||||
enabled = acceptedTerms.value,
|
||||
onClick = {
|
||||
if (!acceptedTerms.value) {
|
||||
termsAcceptanceIsRequired =
|
||||
context.getString(R.string.acceptance_of_terms_is_required)
|
||||
termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required)
|
||||
}
|
||||
|
||||
if (key.value.text.isBlank()) {
|
||||
@@ -281,15 +342,57 @@ fun LoginPage(
|
||||
shape = RoundedCornerShape(Size35dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
.height(50.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.login))
|
||||
}
|
||||
}
|
||||
|
||||
if (PackageUtils.isAmberInstalled(context)) {
|
||||
Box(modifier = Modifier.padding(40.dp, 40.dp, 40.dp, 0.dp)) {
|
||||
Button(
|
||||
enabled = acceptedTerms.value,
|
||||
onClick = {
|
||||
if (!acceptedTerms.value) {
|
||||
termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required)
|
||||
return@Button
|
||||
}
|
||||
|
||||
val result = ExternalSignerUtils.getDataFromResolver(SignerType.GET_PUBLIC_KEY, arrayOf("login"))
|
||||
if (result == null) {
|
||||
loginWithExternalSigner = true
|
||||
return@Button
|
||||
} else {
|
||||
key.value = TextFieldValue(result)
|
||||
if (key.value.text.isBlank()) {
|
||||
errorMessage = context.getString(R.string.key_is_required)
|
||||
return@Button
|
||||
}
|
||||
|
||||
if (acceptedTerms.value && key.value.text.isNotBlank()) {
|
||||
try {
|
||||
accountViewModel.startUI(
|
||||
key.value.text,
|
||||
useProxy.value,
|
||||
proxyPort.value.toInt(),
|
||||
true
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Login", "Could not sign in", e)
|
||||
errorMessage = context.getString(R.string.invalid_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(Size35dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.login_with_external_signer))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The last child is glued to the bottom.
|
||||
@@ -315,7 +418,3 @@ fun LoginPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isPackageInstalled(context: Context, target: String): Boolean {
|
||||
return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null
|
||||
}
|
||||
|
||||
@@ -24,3 +24,9 @@ val DarkerGreen = Color.Green.copy(alpha = 0.32f)
|
||||
val WarningColor = Color(0xFFC62828)
|
||||
|
||||
val RelayIconFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
|
||||
|
||||
val LightWarningColor = Color(0xFFffcc00)
|
||||
val DarkWarningColor = Color(0xFFF8DE22)
|
||||
|
||||
val LightAllGoodColor = Color(0xFF339900)
|
||||
val DarkAllGoodColor = Color(0xFF99cc33)
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.patrykandpatrick.vico.compose.style.ChartStyle
|
||||
@@ -286,6 +287,12 @@ val Colors.overPictureBackground: Color
|
||||
val Colors.bitcoinColor: Color
|
||||
get() = if (isLight) BitcoinLight else BitcoinDark
|
||||
|
||||
val Colors.warningColor: Color
|
||||
get() = if (isLight) LightWarningColor else DarkWarningColor
|
||||
|
||||
val Colors.allGoodColor: Color
|
||||
get() = if (isLight) LightAllGoodColor else DarkAllGoodColor
|
||||
|
||||
val Colors.markdownStyle: RichTextStyle
|
||||
get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark
|
||||
|
||||
@@ -341,11 +348,14 @@ fun AmethystTheme(themeViewModel: ThemeViewModel, content: @Composable () -> Uni
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
if (darkTheme) {
|
||||
window.statusBarColor = colors.background.toArgb()
|
||||
} else {
|
||||
window.statusBarColor = colors.primary.toArgb()
|
||||
}
|
||||
window.navigationBarColor = colors.background.toArgb()
|
||||
insetsController.isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,503 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"></resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="point_to_the_qr_code">QR কোডের দিকে নির্দেশ করুন</string>
|
||||
<string name="show_qr">QR দেখান</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>
|
||||
<string name="post_not_found">ইভেন্টটি লোড হচ্ছে অথবা আপনার রিলে তালিকায় এটি পাওয়া যাচ্ছে না</string>
|
||||
<string name="channel_image">চ্যানেল ইমেজ</string>
|
||||
<string name="referenced_event_not_found">উল্লেখিত ইভেন্টটি পাওয়া যায় নি</string>
|
||||
<string name="could_not_decrypt_the_message">মেসেজটি ডিক্রিপ্ট করা যায় নি</string>
|
||||
<string name="group_picture">দলগত ছবি</string>
|
||||
<string name="explicit_content">খোলামেলা আধেয়</string>
|
||||
<string name="spam">স্প্যাম</string>
|
||||
<string name="impersonation">ছদ্মবেশ</string>
|
||||
<string name="illegal_behavior">বেআইনী আচরণ</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">লেখক আইডি কপি করুন</string>
|
||||
<string name="copy_note_id">নোটের আইডি কপি করুন</string>
|
||||
<string name="broadcast">সম্প্রচার</string>
|
||||
<string name="request_deletion">নোটটি মুছে ফেলার অনুরোধ করুন</string>
|
||||
<string name="block_report">ব্লক/অভিযুক্ত করুন</string>
|
||||
<string name="block_hide_user"><![CDATA[ব্লক করুন এবং ব্যবহারকারীকে আড়ালে রাখুন]]></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="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>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">জ্যাপের কোনো পরিমাণ নির্দিষ্ট করা নেই। এটি পরিবর্তন করতে চাইলে দীর্ঘক্ষণ চাপুন</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">কাউকে জ্যাপ পাঠাতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">কাউকে অনুসরণ করতে ব্যক্তিগত চাবি দিয়ে লগইন করুন</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">কাউকে আনফলো করতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন</string>
|
||||
<string name="zaps">জ্যাপস</string>
|
||||
<string name="view_count">পরিমাণ দেখুন</string>
|
||||
<string name="boost">বুস্ট</string>
|
||||
<string name="boosted">বুস্ট করা হয়েছে</string>
|
||||
<string name="quote">উদ্ধৃতি দিন</string>
|
||||
<string name="new_amount_in_sats">স্যাটে নতুন পরিমাণ যুক্ত হয়েছে</string>
|
||||
<string name="add">যুক্ত করুন</string>
|
||||
<string name="replying_to">"এই ব্যক্তির প্রতিউত্তর দিচ্ছেন "</string>
|
||||
<string name="and">" এবং "</string>
|
||||
<string name="in_channel">"চ্যানেলের ভেতর "</string>
|
||||
<string name="profile_banner">প্রোফাইলের ব্যানার</string>
|
||||
<string name="payment_successful">সফলভাবে মূল্য পরিশোধিত হয়েছে</string>
|
||||
<string name="error_parsing_error_message">Error parsing error message</string>
|
||||
<string name="following">" অনুসরণ"</string>
|
||||
<string name="followers">" অনুসারী"</string>
|
||||
<string name="profile">প্রোফাইল</string>
|
||||
<string name="security_filters">নিরাপত্তা-ফিল্টার</string>
|
||||
<string name="log_out">লগ আউট</string>
|
||||
<string name="show_more">আরও দেখান</string>
|
||||
<string name="lightning_invoice">বিজলি-ইনভয়েস</string>
|
||||
<string name="pay">পরিশোধ করুন</string>
|
||||
<string name="lightning_tips">বিজলি-বকশিশ</string>
|
||||
<string name="note_to_receiver">প্রাপকের উদ্দেশে চিরকুট</string>
|
||||
<string name="thank_you_so_much">আপনাকে অনেক ধন্যবাদ!</string>
|
||||
<string name="amount_in_sats">স্যাটে মোট পরিমাণ</string>
|
||||
<string name="send_sats">স্যাট পাঠান</string>
|
||||
<string name="error_parsing_preview_for">"এই দুইয়ের পূর্বরূপ দেখানো যাচ্ছে না %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"%1$s এর জন্য কার্ড ইমেজের পূর্বরূপ দেখুন"</string>
|
||||
<string name="new_channel">নতুন চ্যানেল</string>
|
||||
<string name="channel_name">চ্যানেলের নাম</string>
|
||||
<string name="my_awesome_group">আমার চমৎকার গ্রুপ</string>
|
||||
<string name="picture_url">ছবির 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="save">সংরক্ষণ করুন</string>
|
||||
<string name="create">তৈরি করুন</string>
|
||||
<string name="cancel">বাতিল করুন</string>
|
||||
<string name="failed_to_upload_the_image">ছবিটি সফলভাবে আপলোড করা যায় নি</string>
|
||||
<string name="relay_address">রিলে-ঠিকানা</string>
|
||||
<string name="posts">পোস্টগুলি</string>
|
||||
<string name="bytes">বাইটস</string>
|
||||
<string name="errors">ত্রুটিসমূহ</string>
|
||||
<string name="home_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="about_me">আমার সম্পর্কে</string>
|
||||
<string name="avatar_url">অবতারের URL</string>
|
||||
<string name="banner_url">ব্যানারের URL</string>
|
||||
<string name="website_url">ওয়েবসাইটের URL</string>
|
||||
<string name="ln_address">বিজলি-ঠিকানা</string>
|
||||
<string name="ln_url_outdated">বিজলি 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="reply_here">"এখানে প্রতিউত্তর দিন.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">নস্টারে শেয়ারের জন্য নোট আইডিটি ক্লিপবোর্ডে কপি করা হয়</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">চ্যানেল আইডি (নোট) ক্লিপবোর্ডে কপি করুন</string>
|
||||
<string name="edits_the_channel_metadata">চ্যানেলের মেটাডেটা সম্পাদনা করে</string>
|
||||
<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="conversations">কথোপকথন</string>
|
||||
<string name="notes">নোটস</string>
|
||||
<string name="replies">প্রতিউত্তরসমূহ</string>
|
||||
<string name="follows">"অনুসারী"</string>
|
||||
<string name="reports">"অভিযোগসমূহ"</string>
|
||||
<string name="more_options">আরও বিকল্প</string>
|
||||
<string name="relays">" রিলেগুলি"</string>
|
||||
<string name="website">ওয়েবসাইট</string>
|
||||
<string name="lightning_address">বিজলি-ঠিকানা</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">ব্যাকআপের জন্য আপনার Nsec আইডি (তথা পাসওয়ার্ড) ক্লিপবোর্ডে কপি করে</string>
|
||||
<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="follow">অনুসরণ করুন</string>
|
||||
<string name="follow_back">প্রতি-অনুসরণ করুন</string>
|
||||
<string name="unblock">আনব্লক করুন</string>
|
||||
<string name="copy_user_id">ব্যবহারকারীর আইডি কপি করুন</string>
|
||||
<string name="unblock_user">ব্যবহারকারীকে আনব্লক করুন</string>
|
||||
<string name="npub_hex_username">"গণচাবি, ইউজারনেম, টেক্সট"</string>
|
||||
<string name="clear">মুছে ফেলুন</string>
|
||||
<string name="app_logo">অ্যাপের লোগো</string>
|
||||
<string name="nsec_npub_hex_private_key">Nsec.. অথবা Npub..</string>
|
||||
<string name="show_password">পাসওয়ার্ড দেখান</string>
|
||||
<string name="hide_password">পাসওয়ার্ড লুকান</string>
|
||||
<string name="invalid_key">অকেজো চাবি</string>
|
||||
<string name="i_accept_the">"আমি এটা মেনে নিচ্ছি "</string>
|
||||
<string name="terms_of_use">ব্যবহারের শর্তাবলী</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="loading_feed">পাতাটি লোড করা হচ্ছে</string>
|
||||
<string name="error_loading_replies">"প্রতিউত্তরগুলি লোড করা যাচ্ছে না: "</string>
|
||||
<string name="try_again">আবার চেষ্টা করুন</string>
|
||||
<string name="feed_is_empty">পাতাটি খালি</string>
|
||||
<string name="refresh">রিফ্রেশ করুন</string>
|
||||
<string name="created">তৈরি করা হয়েছে</string>
|
||||
<string name="with_description_of">এর বিবরণ সহ</string>
|
||||
<string name="and_picture">এবং ছবি</string>
|
||||
<string name="changed_chat_name_to">চ্যাটের নাম পরিবর্তন করুন</string>
|
||||
<string name="description_to">বিবরণ</string>
|
||||
<string name="and_picture_to">এবং ছবি</string>
|
||||
<string name="leave">ছেড়ে দিন</string>
|
||||
<string name="unfollow">আনফলো করুন</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_to">থেকে</string>
|
||||
<string name="translations_show_in_lang_first">প্রথমে %1$s ভাষায় দেখান</string>
|
||||
<string name="translations_always_translate_to_lang">সবসময় %1$s ভাষায় অনুবাদ করুন</string>
|
||||
<string name="translations_never_translate_from_lang">কখনোই %1$s ভাষা থেকে অনুবাদ করবেন না</string>
|
||||
<string name="nip_05">নস্টার ঠিকানা</string>
|
||||
<string name="never">কখনো না</string>
|
||||
<string name="now">এখন</string>
|
||||
<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="account_backup_tips_md" tools:ignore="Typos"> ## চাবির ব্যাকআপ এবং নিরাপত্তা বিষয়ক টিপস
|
||||
\n\nআপনার অ্যাকাউন্ট একটি ব্যক্তিগত চাবি দ্বারা সুরক্ষিত। চাবি হলো এলোমেলো অক্ষর ও সংখ্যার একটি দীর্ঘ তন্তু যা **nsec** দ্বারা শুরু হয়। আপনার ব্যক্তিগত চাবিটি পেলে যে কেউ আপনার পরিচয় ব্যবহার করে যেকোনো আধেয় প্রকাশ করতে পারবে।
|
||||
\n\n- আপনি বিশ্বাস করেন না এমন কোনো ওয়েবসাইট কিংবা সফটওয়্যারে আপনার ব্যক্তিগত চাবিটি রাখবেন **না**।
|
||||
\n- অ্যামেথিস্টের ডেভেলপাররা **কখনোই** আপনার কাছে আপনার ব্যক্তিগত চাবিটি জানতে চাইবে না।
|
||||
\n- আপনার ব্যক্তিগত চাবির একটি নিরাপদ ব্যাকআপ **অবশ্যই** সংরক্ষণ করবেন কারণ এটি আপনার অ্যাকাউন্ট পুনরুদ্ধারে কাজে আসবে। আমরা একটি পাসওয়ার্ড ম্যানেজার ব্যবহারের পরামর্শ দিই।
|
||||
</string>
|
||||
<string name="secret_key_copied_to_clipboard">ব্যক্তিগত চাবি (nsec) ক্লিপবোর্ডে কপি করা হয়েছে</string>
|
||||
<string name="copy_my_secret_key">আমার ব্যক্তিগত চাবিটি কপি করুন</string>
|
||||
<string name="biometric_authentication_failed">প্রমাণীকরণ ব্যর্থ হয়েছে।</string>
|
||||
<string name="biometric_error">ত্রুটি</string>
|
||||
<string name="badge_created_by">"%1$s দ্বারা নির্মিত"</string>
|
||||
<string name="badge_award_image_for">"%1$s -এর জন্য ব্যাজ পুরষ্কারের ছবি"</string>
|
||||
<string name="new_badge_award_notif">আপনি একটি নতুন ব্যাজ পুরষ্কার পেয়েছেন</string>
|
||||
<string name="award_granted_to">ব্যাজ পুরস্কারটি পাচ্ছে</string>
|
||||
<string name="copied_note_text_to_clipboard">নোটের রচনাটি ক্লিপবোর্ডে কপি করা হয়েছে</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">রচয়িতার @npub ক্লিপবোর্ডে কপি করুন</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">নোট আইডিটি (@note1) ক্লিপবোর্ডে কপি করা হয়েছে</string>
|
||||
<string name="select_text_dialog_top">রচনাটি নির্বাচন করুন</string>
|
||||
<string name="private_conversation_notification">"<Unable to decrypt private message>\n\nআপনাকে %1$s এবং %2$s এর মধ্যে একটি ব্যক্তিগত/এনক্রিপ্টেড কথোপকথনে উদ্ধৃত করা হয়েছে৷"</string>
|
||||
<string name="account_switch_add_account_dialog_title">নতুন অ্যাকাউন্ট যুক্ত করুন</string>
|
||||
<string name="drawer_accounts">অ্যাকাউন্টগুলি</string>
|
||||
<string name="account_switch_select_account">অ্যাকাউন্ট নির্বাচন করুন</string>
|
||||
<string name="account_switch_add_account_btn">নতুন অ্যাকাউন্ট যুক্ত করুন</string>
|
||||
<string name="account_switch_active_account">অ্যাকাউন্ট সক্রিয় করুন</string>
|
||||
<string name="account_switch_has_private_key">ব্যক্তিগত চাবি আছে</string>
|
||||
<string name="account_switch_pubkey_only">শুধুমাত্র পাঠযোগ্য, ব্যক্তিগত চাবি নেই</string>
|
||||
<string name="back">পিছে যান</string>
|
||||
<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_delete">মুছে ফেলুন</string>
|
||||
<string name="quick_action_unfollow">আনফলো করুন</string>
|
||||
<string name="quick_action_follow">অনুসরণ করুন</string>
|
||||
<string name="quick_action_request_deletion_alert_title">নোটটি মুছে ফেলার অনুরোধ করুন</string>
|
||||
<string name="quick_action_request_deletion_alert_body">অ্যামেথিস্ট অনুরোধ করবে যেন আপনার নোটটি আপনার বর্তমানে সংযুক্ত রিলেগুলো থেকে মুছে ফেলা হয়। তবে কোনো নিশ্চয়তা নেই যে নোটটি স্থায়ীভাবে সেই রিলেগুলি থেকে বা অন্যান্য রিলে থেকে মুছে ফেলা হবে, যেখানে এটি সংরক্ষণ করা হতে পারে৷</string>
|
||||
<string name="quick_action_block_dialog_btn">ব্লক করুন</string>
|
||||
<string name="quick_action_delete_dialog_btn">মুছে ফেলুন</string>
|
||||
<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="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"><![CDATA[ব্যবহারকারীকে ব্লক করুন এবং আড়ালে রাখুন]]></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_post_report_btn">অভিযোগ পেশ করুন</string>
|
||||
<string name="report_dialog_title">ব্লক করুন এবং অভিযোগ পেশ করুন</string>
|
||||
<string name="block_only">ব্লক করুন</string>
|
||||
<string name="bookmarks">বুকমার্ক করুন</string>
|
||||
<string name="private_bookmarks">ব্যক্তিগত বুকমার্ক</string>
|
||||
<string name="public_bookmarks">সর্বজনীন বুকমার্ক</string>
|
||||
<string name="add_to_private_bookmarks">ব্যক্তিগত বুকমার্কে যুক্ত করুন</string>
|
||||
<string name="add_to_public_bookmarks">সর্বজনীন বুকমার্কে যুক্ত করুন</string>
|
||||
<string name="remove_from_private_bookmarks">ব্যক্তিগত বুকমার্ক থেকে সরিয়ে দিন</string>
|
||||
<string name="remove_from_public_bookmarks">সর্বজনীন বুকমার্ক থেকে সরিয়ে দিন</string>
|
||||
<string name="wallet_connect_service">ওয়ালেট সংযোগের পরিষেবা</string>
|
||||
<string name="wallet_connect_service_explainer">অ্যাপ থেকে বের না হয়েই জ্যাপ প্রদান করতে একটি নস্টার-সিক্রেট অনুমোদন করা হয়ে থাকে। এই নস্টার-সিক্রেটটি নিরাপদে রাখুন এবং সম্ভব হলে একটি ব্যক্তিগত রিলে ব্যবহার করুন</string>
|
||||
<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_secret_placeholder">nsec / hex ব্যক্তিগত চাবি</string>
|
||||
<string name="pledge_amount_in_sats">প্রতিশ্রুত পরিমাণ স্যাট দ্বারা জানান</string>
|
||||
<string name="post_poll">একটি পোল পেশ করুন</string>
|
||||
<string name="poll_heading_required">প্রয়োজনীয় ক্ষেত্রসমূহ:</string>
|
||||
<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_heading_optional">ঐচ্ছিক ক্ষেত্র:</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">(০–১০০)%</string>
|
||||
<string name="poll_closing_time">এই সময় পর বন্ধ করুন</string>
|
||||
<string name="poll_closing_time_days">দিনগুলি</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="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="content_description_add_image">ছবি যোগ করুন</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_example">সূর্যাস্তের সময় সাদা বালুকাময় সমুদ্র সৈকতে একটি নীল নৌকা</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>
|
||||
<string name="zap_type_private">ব্যক্তিগত</string>
|
||||
<string name="zap_type_private_explainer">প্রেরক এবং প্রাপক একে অপরকে দেখতে এবং বার্তা পড়তে পারেন</string>
|
||||
<string name="zap_type_anonymous">অজ্ঞাত-পরিচয়</string>
|
||||
<string name="zap_type_anonymous_explainer">প্রাপক এবং জনসাধারণ জানেন না কে প্রদত্ত অর্থ পাঠিয়েছেন</string>
|
||||
<string name="zap_type_nonzap">নন-জ্যাপ</string>
|
||||
<string name="zap_type_nonzap_explainer">নস্টারে কোনো সন্ধান নেই, কেবল বিজলিতে আছে</string>
|
||||
<string name="file_server">ফাইল সার্ভার</string>
|
||||
<string name="zap_forward_lnAddress">বিজলি-ঠিকানা বা @ব্যবহারকারী</string>
|
||||
<string name="upload_server_imgur">imgur.com - বিশ্বস্ত</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_nostrbuild">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_nostrcheckme">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_nostrimg_nip94">যাচাইযোগ্য NostrImg (NIP-94)</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_nostrfilesdev_nip94">যাচাইযোগ্য Nostrfiles.dev (NIP-94)</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_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">আপনার Orbot সেটআপের মাধ্যমে সংযুক্ত হোন</string>
|
||||
<string name="do_you_really_want_to_disable_tor_title">Orbot/Tor থেকে সংযোগ বিচ্ছিন্ন করতে চান?</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_kind3follows">অনুসৃত সকলেরা</string>
|
||||
<string name="follow_list_global">বৈশ্বিক</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown"> ## Tor এর মাধ্যমে Orbot এর সাথে সংযুক্ত হোন
|
||||
\n\n১. [Orbot] নামান (https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n২. Orbot চালু করুন
|
||||
\n৩. Orbot এ ঢুকে Socks পোর্টটি খেয়াল করুন। এটি ৯০৫০ তে নির্দিষ্ট করা থাকে
|
||||
\n৪. প্রয়োজনে Orbot এ পোর্টটি বদলে দিন
|
||||
\n৫. স্ক্রিনের Socks পোর্টটি প্রস্তুত করুন
|
||||
\n৬. Orbot কে প্রক্সি হিসেবে ব্যবহার করতে Activate বোতামটি চাপুন
|
||||
</string>
|
||||
<string name="orbot_socks_port">Orbot এর Socks পোর্ট</string>
|
||||
<string name="invalid_port_number">পোর্ট নম্বরটি অকার্যকর</string>
|
||||
<string name="use_orbot">Orbot ব্যবহার করুন</string>
|
||||
<string name="disconnect_from_your_orbot_setup">Tor/Orbot থেকে সংযোগ বিচ্ছিন্ন করুন</string>
|
||||
<string name="app_notification_dms_channel_name">ব্যক্তিগত বার্তাসমূহ</string>
|
||||
<string name="app_notification_dms_channel_description">যখন একটি ব্যক্তিগত বার্তা আসে তখন আপনাকে অবহিত করে</string>
|
||||
<string name="app_notification_zaps_channel_name">জ্যাপ গৃহীত হয়েছে</string>
|
||||
<string name="app_notification_zaps_channel_description">কেউ জ্যাপ পাঠালে আপনাকে অবহিত করে</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s স্যাট</string>
|
||||
<string name="app_notification_zaps_channel_message_from">%1$s থেকে</string>
|
||||
<string name="app_notification_zaps_channel_message_for">%1$s এর জন্য</string>
|
||||
<string name="reply_notify">অবহিত করুন: </string>
|
||||
<string name="channel_list_join_conversation">আলোচনায় যুক্ত হোন</string>
|
||||
<string name="channel_list_user_or_group_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>
|
||||
<string name="today">আজ</string>
|
||||
<string name="content_warning">আধেয় বিষয়ক সতর্কতা</string>
|
||||
<string name="content_warning_explanation">এই পোস্টে সংবেদনশীল উপাদান রয়েছে যা কারো জন্য আপত্তিকর কিংবা সমস্যাজনক হতে পারে</string>
|
||||
<string name="content_warning_hide_all_sensitive_content">সংবেদনশীল আধেয় সবসময় আড়ালে রাখুন</string>
|
||||
<string name="content_warning_show_all_sensitive_content">সংবেদনশীল বিষয়বস্তু সবসময় খোলামেলা দেখান</string>
|
||||
<string name="content_warning_see_warnings">সবসময় আধেয় বিষয়ক সতর্কতা দেখান</string>
|
||||
<string name="recommended_apps">প্রস্তাবিত: </string>
|
||||
<string name="filter_spam_from_strangers">অপরিচিতদের কাছ থেকে স্প্যাম ফিল্টার করুন</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows">কোনো পোস্টে আপনার অনুসরীদের থেকে অভিযোগ থাকলে অবহিত হোন</string>
|
||||
<string name="new_reaction_symbol">প্রতিক্রিয়ার নতুন প্রতীক</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">প্রতিক্রিয়ার কোনো ধরন বেছে নেওয়া হয়নি। পরিবর্তন করতে দীর্ঘক্ষণ চাপুন</string>
|
||||
<string name="zapraiser">জ্যাপরেইজার</string>
|
||||
<string name="zapraiser_explainer">এই পোস্টের জন্য স্যাটের একটি লক্ষ্যমাত্রা যোগ করে। সহায়তাকারী ক্লায়েন্টরা অনুদানকে উৎসাহিত করার জন্য একে একটি অগ্রগতির মানদণ্ড হিসাবে দেখাতে পারে</string>
|
||||
<string name="zapraiser_target_amount_in_sats">পরিমাণের লক্ষ্যমাত্রা স্যাট দ্বারা জানান</string>
|
||||
<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_occurred_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="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="message_length">বার্তার ব্যাপ্তি</string>
|
||||
<string name="subscriptions">সকল সদস্যতা</string>
|
||||
<string name="filters">ফিল্টারগুলি</string>
|
||||
<string name="subscription_id_length">সদস্যতা আইডির ব্যাপ্তি</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="auth">অনুমোদন</string>
|
||||
<string name="payment">মূল্যপরিশোধ</string>
|
||||
<string name="cashu">ক্যাশু টোকেন</string>
|
||||
<string name="cashu_redeem">খালাস করুন</string>
|
||||
<string name="no_lightning_address_set">কোনো বিজলি-ঠিকানা সেট করা নেই</string>
|
||||
<string name="copied_token_to_clipboard">টোকেনটি ক্লিপবোর্ডে কপি করা হয়েছে</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_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="relay_setup">রিলেগুলি</string>
|
||||
<string name="discover_live">লাইভ</string>
|
||||
<string name="discover_community">কম্যুনিটি</string>
|
||||
<string name="discover_chat">বার্তালাপগুলি</string>
|
||||
<string name="community_approved_posts">অনুমোদিত পোস্টগুলি</string>
|
||||
<string name="groups_no_descriptor">এই দলটির কোনো বিবরণ কিংবা নীতিমালা নেই। এগুলো যুক্ত করতে দলনেতার সাথে কথা বলুন</string>
|
||||
<string name="community_no_descriptor">এই গোষ্ঠীর কোনো বিবরণ কিংবা নীতিমালা নেই। এগুলো যুক্ত করতে এর মালিকের সাথে কথা বলুন</string>
|
||||
<string name="add_sensitive_content_label">সংবেদনশীল আধেয়</string>
|
||||
<string name="add_sensitive_content_description">সংবেদনশীল আধেয় দেখানোর আগে এ সম্পর্কিত সতর্কতা প্রদর্শন করে</string>
|
||||
<string name="settings">সেটিংস</string>
|
||||
<string name="connectivity_type_always">সবসময়</string>
|
||||
<string name="connectivity_type_wifi_only">শুধুমাত্র ওয়াইফাইতে</string>
|
||||
<string name="connectivity_type_never">কখনো না</string>
|
||||
<string name="system">সিস্টেম</string>
|
||||
<string name="light">উজ্জ্বল</string>
|
||||
<string name="dark">আঁধারি</string>
|
||||
<string name="application_preferences">অ্যাপ্লিকেশনের পছন্দসমূহ</string>
|
||||
<string name="language">ভাষা</string>
|
||||
<string name="theme">থিম</string>
|
||||
<string name="automatically_load_images_gifs">ছবির পূর্বরূপ</string>
|
||||
<string name="automatically_play_videos">ভিডিও প্লেব্যাক</string>
|
||||
<string name="automatically_show_url_preview">URL-এর পূর্বরূপ</string>
|
||||
<string name="load_image">ছবিটি লোড করুন</string>
|
||||
<string name="spamming_users">স্প্যামাররা</string>
|
||||
<string name="muted_button">মৌন করে রাখা। এটি তুলে নিতে ক্লিক করুন</string>
|
||||
<string name="mute_button">শব্দ চালু আছে। মৌন করতে ক্লিক করুন</string>
|
||||
<string name="search_button">স্থানীয় এবং দূরবর্তী রেকর্ড অনুসন্ধান করুন</string>
|
||||
<string name="nip05_verified">নস্টার ঠিকানাটি যাচাই করা হয়েছে</string>
|
||||
<string name="nip05_failed">নস্টার ঠিকানাটি যাচাই করা যায় নি</string>
|
||||
<string name="nip05_checking">নস্টার ঠিকানাটি পরীক্ষা করে দেখা হচ্ছে</string>
|
||||
<string name="select_deselect_all">সবগুলিকে নির্বাচন/অনির্বাচন করুন</string>
|
||||
<string name="default_relays">পূর্ব-নির্ধারিত</string>
|
||||
<string name="select_a_relay_to_continue">চালিয়ে যেতে একটি রিলে নির্বাচন করুন</string>
|
||||
<string name="zap_forward_title">জ্যাপটি পাঠান:</string>
|
||||
<string name="zap_forward_explainer">সহায়তাকারী ক্লায়েন্টরা আপনার পরিবর্তে নিচের বিজলি-ঠিকানায় বা ব্যবহারকারীর প্রোফাইলে জ্যাপ পাঠাবে</string>
|
||||
<string name="geohash_title">আপনার অবস্থান এভাবে প্রকাশ করুন </string>
|
||||
<string name="geohash_explainer">পোস্টে আপনার অবস্থানের একটি জিওহ্যাশ যোগ করে। জনসাধারণ জানবে যে আপনি বর্তমান অবস্থান থেকে ৫ কিলোমিটার (৩ মাইল) এর মধ্যে আছেন</string>
|
||||
<string name="add_sensitive_content_explainer">আপনার পেশকৃত আধেয় দেখানোর আগে সংবেদনশীল আধেয়-সতর্কতা জারি করে। এটি সেসকল আধেয়র জন্য আদর্শ যেখানে NSFW উপাদান কিংবা এমনকিছু থাকতে পারে যা কারো কারো জন্য আপত্তিকর কিংবা সমস্যাজনক মনে হতে পারে।</string>
|
||||
<string name="new_feature_nip24_might_not_be_available_title">নতুন ফিচার</string>
|
||||
<string name="new_feature_nip24_might_not_be_available_description">এই মোড সক্রিয় করার জন্য অ্যামেথিস্টকে একটি NIP-24 বার্তা পাঠাতে হবে। (GiftWrapped, সিল করা সরাসরি এবং দলগত বার্তা)। NIP-24 নতুন এবং বেশিরভাগ ক্লায়েন্ট এখনও এটি বাস্তবায়ন করেনি। নিশ্চিত করুন যে প্রাপক একটি সামঞ্জস্যপূর্ণ ক্লায়েন্ট ব্যবহার করছে।</string>
|
||||
<string name="new_feature_nip24_activate">সক্রিয় করুন</string>
|
||||
<string name="messages_create_public_chat">সর্বজনীন</string>
|
||||
<string name="messages_new_message">ব্যক্তিগত</string>
|
||||
<string name="messages_new_message_to">প্রতি</string>
|
||||
<string name="messages_new_message_subject">বিষয়</string>
|
||||
<string name="messages_new_message_subject_caption">আলোচনার বিষয়</string>
|
||||
<string name="messages_new_message_to_caption">"\@User1, @User2, @User3"</string>
|
||||
<string name="messages_group_descriptor">এই দলের সদস্যরা</string>
|
||||
<string name="messages_new_subject_message">সদস্যদের নিকট ব্যাখ্যা</string>
|
||||
<string name="messages_new_subject_message_placeholder">নতুন লক্ষ্যের জন্য নাম পরিবর্তন করা হচ্ছে।</string>
|
||||
<string name="paste_from_clipboard">ক্লিপবোর্ড থেকে পেস্ট করুন</string>
|
||||
<string name="language_description">অ্যাপের ইন্টারফেসের জন্য</string>
|
||||
<string name="theme_description">আঁধারি, উজ্জ্বল কিংবা সিস্টেমের থিম</string>
|
||||
<string name="automatically_load_images_gifs_description">স্বয়ংক্রিয়ভাবে ছবি এবং GIF লোড করুন</string>
|
||||
<string name="automatically_play_videos_description">স্বয়ংক্রিয়ভাবে ভিডিও এবং GIF চালু করুন</string>
|
||||
<string name="automatically_show_url_preview_description">URL এর পূর্বরূপ দেখান</string>
|
||||
<string name="load_image_description">কখন ছবি লোড করতে হবে</string>
|
||||
<string name="copy_url_to_clipboard">URL ক্লিপবোর্ডে কপি করুন</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">নোট আইডি ক্লিপবোর্ডে কপি করুন</string>
|
||||
<string name="created_at">যেখানে নির্মিত</string>
|
||||
<string name="rules">নিয়ম</string>
|
||||
<string name="login_with_external_signer">Amber দিয়ে লগইন করুন</string>
|
||||
<string name="status_update">আপনার স্ট্যাটাস আপডেট করুন</string>
|
||||
<string name="lightning_wallets_not_found">Error parsing error message</string>
|
||||
<string name="poll_zap_value_min_max_explainer">জ্যাপের পরিমাণ দ্বারা ভোটসংখ্যা গোণা হয়। আপনি স্প্যামার এড়াতে একটি সর্বনিম্ন পরিমাণ এবং একটি বড়ো জ্যাপার দ্বারা পোল দখল এড়াতে সর্বোচ্চ পরিমাণ স্যাট নির্ধারণ করতে পারেন। প্রতিটি ভোটের মূল্য একই পরিমাণে রয়েছে তা নিশ্চিত করতে উভয় ক্ষেত্রেই একই পরিমাণ ব্যবহার করুন। যেকোনো পরিমাণ গ্রহণ করতে এটি খালি ছেড়ে দিন।</string>
|
||||
<string name="error_dialog_zap_error">জ্যাপ পাঠানো যায় নি</string>
|
||||
<string name="error_dialog_talk_to_user">ব্যবহারকারীকে বার্তা দিন</string>
|
||||
<string name="error_dialog_button_ok">ঠিক আছে</string>
|
||||
<string name="relay_information_document_error_assemble_url">%1$s: %2$s পৌঁছাতে ব্যর্থ</string>
|
||||
<string name="relay_information_document_error_reach_server">%1$s: %2$s পৌঁছাতে ব্যর্থ</string>
|
||||
<string name="relay_information_document_error_parse_result">%1$s: %2$s থেকে ফলাফল পার্স করা যায় নি</string>
|
||||
<string name="relay_information_document_error_http_status">%1$s কোড দিয়ে ব্যর্থ হয়েছে %2$s</string>
|
||||
<string name="active_for">এর জন্য সক্রিয় করুন: </string>
|
||||
<string name="active_for_home">নীড়</string>
|
||||
<string name="active_for_msg">বার্তাসমূহ</string>
|
||||
<string name="active_for_chats">আলাপচারিতা</string>
|
||||
<string name="active_for_global">বৈশ্বিক</string>
|
||||
<string name="active_for_search">অনুসন্ধান করুন</string>
|
||||
<string name="zap_split_title">জ্যাপগুলি ভাগ করুন এবং পাঠান</string>
|
||||
<string name="zap_split_explainer">সমর্থিত ক্লায়েন্টরা জ্যাপ ভাগ করবে এবং আপনার পরিবর্তে এখানে সংযুক্ত ব্যবহারকারীদেরকে পাঠাবে</string>
|
||||
<string name="zap_split_serarch_and_add_user">ব্যবহারকারীদের অনুসন্ধান করুন এবং যুক্ত করুন</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">ইউজারনেম কিংবা প্রদর্শিত নাম দিন</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">%1$s ব্যবহারকারীর স্যাট গ্রহণের উপযোগী কোনো বিজলি ঠিকানা সেট করা নেই</string>
|
||||
<string name="zap_split_weight">শতাংশ</string>
|
||||
<string name="zap_split_weight_placeholder">২৫</string>
|
||||
<string name="splitting_zaps_with">এর সাথে জ্যাপ ভাগ করা হচ্ছে</string>
|
||||
<string name="forwarding_zaps_to">একে জ্যাপ পাঠানো হচ্ছে</string>
|
||||
<string name="lightning_wallets_not_found2">বিজলি-ওয়ালেট পাওয়া যায় নি</string>
|
||||
<string name="paid">পরিশোধিত</string>
|
||||
<string name="wallet_number">ওয়ালেট %1$s</string>
|
||||
<string name="error_opening_external_signer">স্বাক্ষরের অ্যাপ খোলা যাচ্ছে না</string>
|
||||
<string name="sign_request_rejected">স্বাক্ষরের অনুরোধ প্রত্যাখ্যান করা হয়েছে</string>
|
||||
</resources>
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Přihlaste se pomocí soukromého klíče, abyste mohli zrušit sledování</string>
|
||||
<string name="zaps">Zapy</string>
|
||||
<string name="view_count">Počet zobrazení</string>
|
||||
<string name="boosted">boostováno</string>
|
||||
<string name="boost">Zvýšení</string>
|
||||
<string name="boosted">zvýšeno</string>
|
||||
<string name="quote">Citovat</string>
|
||||
<string name="new_amount_in_sats">Nová částka v sats</string>
|
||||
<string name="add">Přidat</string>
|
||||
@@ -290,6 +291,7 @@
|
||||
<string name="zap_type_private_explainer">Odesílatel a příjemce si mohou navzájem vidět a číst zprávu</string>
|
||||
<string name="zap_type_anonymous">Anonymní</string>
|
||||
<string name="zap_type_anonymous_explainer">Příjemce a veřejnost neví, kdo platbu poslal</string>
|
||||
<string name="zap_type_nonzap">Né Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Žádná stopa v Nostr, pouze v Lightning</string>
|
||||
<string name="file_server">Souborový server</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress nebo @Uživatel</string>
|
||||
@@ -358,6 +360,7 @@
|
||||
<string name="warn_when_posts_have_reports_from_your_follows">Varovat, když příspěvky obsahují hlášení od vašich sledovaných osob</string>
|
||||
<string name="new_reaction_symbol">Nový symbol reakce</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">Nebyly vybrány žádné typy reakcí. Dlouhým stiskem změňte</string>
|
||||
<string name="zapraiser">Zap-sběr</string>
|
||||
<string name="zapraiser_explainer">Přidá cílovou částku sats pro tento příspěvek. Podporované klienty ji mohou zobrazovat jako ukazatel postupu k podněcování darování</string>
|
||||
<string name="zapraiser_target_amount_in_sats">Cílová částka v sats</string>
|
||||
<string name="sats_to_complete">Zapraiser na %1$s. Do cíle zbývá %2$s sats</string>
|
||||
@@ -451,4 +454,23 @@
|
||||
<string name="automatically_play_videos_description">Automaticky přehrávat videa a GIFy</string>
|
||||
<string name="automatically_show_url_preview_description">Zobrazit náhledy URL</string>
|
||||
<string name="load_image_description">Kdy načíst obrázek</string>
|
||||
<string name="copy_url_to_clipboard">Kopírovat URL do schránky</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopírovat ID poznámky do schránky</string>
|
||||
<string name="created_at">Vytvořeno</string>
|
||||
<string name="rules">Pravidla</string>
|
||||
<string name="status_update">Aktualizovat svůj stav</string>
|
||||
<string name="lightning_wallets_not_found">Chyba při zpracování chybové zprávy</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Hlasy jsou váženy podle hodnoty zapu. Můžete nastavit minimální částku, abyste se vyhnuli spammerům, a maximální částku, abyste zabránili velkým zapperům, kteří by mohli ovládnout hlasování. Použijte stejnou částku v obou polích, aby byla hodnota každého hlasu stejná. Nechte prázdné pro přijetí libovolné částky.</string>
|
||||
<string name="error_dialog_zap_error">Nelze odeslat zap</string>
|
||||
<string name="error_dialog_talk_to_user">Poslat zprávu uživateli</string>
|
||||
<string name="relay_information_document_error_assemble_url">Nepodařilo se dosáhnout %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_reach_server">Nepodařilo se dosáhnout %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_parse_result">Nepodařilo se zpracovat výsledek z %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_http_status">%1$s selhalo s kódem %2$s</string>
|
||||
<string name="active_for">Aktivní pro: </string>
|
||||
<string name="active_for_home">Domů</string>
|
||||
<string name="active_for_msg">Zprávy</string>
|
||||
<string name="active_for_chats">Chaty</string>
|
||||
<string name="active_for_global">Celosvětové</string>
|
||||
<string name="active_for_search">Hledání</string>
|
||||
</resources>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">Melden Sie sich mit einem privaten Schlüssel an, um zu folgen</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Melden Sie sich mit einem privaten Schlüssel an, um nicht mehr zu folgen</string>
|
||||
<string name="view_count">Aufrufe</string>
|
||||
<string name="boost">Verstärken</string>
|
||||
<string name="boosted">Verstärkt</string>
|
||||
<string name="quote">Zitat</string>
|
||||
<string name="new_amount_in_sats">Neuer Betrag in Sats</string>
|
||||
@@ -169,6 +170,8 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="nip_05">Nostr-Adresse</string>
|
||||
<string name="never">nie</string>
|
||||
<string name="now">jetzt</string>
|
||||
<string name="h">s</string>
|
||||
<string name="d">t</string>
|
||||
<string name="nudity">Nacktheit</string>
|
||||
<string name="profanity_hateful_speech">Beleidigungen / Hassrede</string>
|
||||
<string name="report_hateful_speech">Hassrede melden</string>
|
||||
@@ -246,8 +249,11 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="add_to_public_bookmarks">Zu den öffentlichen Lesezeichen hinzufügen</string>
|
||||
<string name="remove_from_private_bookmarks">Aus den privaten Lesezeichen entfernen</string>
|
||||
<string name="remove_from_public_bookmarks">Aus den öffentlichen Lesezeichen entfernen</string>
|
||||
<string name="wallet_connect_service">Wallet-Verbindungsdienst</string>
|
||||
<string name="wallet_connect_service_explainer">Autorisiert einen Nostr Secret, Zaps ohne Verlassen der App zu bezahlen. Bewahren Sie den geheimen Schlüssel sicher auf und verwenden Sie nach Möglichkeit ein privates Relay</string>
|
||||
<string name="wallet_connect_service_pubkey">Wallet Connect Public Key</string>
|
||||
<string name="wallet_connect_service_relay">Wallet-Verbindungs-Relay</string>
|
||||
<string name="wallet_connect_service_secret">Wallet-Verbindungs-Geheimnis</string>
|
||||
<string name="wallet_connect_service_show_secret">Geheimen Schlüssel anzeigen</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / hexadezimaler privater Schlüssel</string>
|
||||
<string name="pledge_amount_in_sats">Spendenbetrag in Sats</string>
|
||||
@@ -288,6 +294,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="zap_type_private_explainer">Absender und Empfänger können einander sehen und die Nachricht lesen</string>
|
||||
<string name="zap_type_anonymous">Anonym</string>
|
||||
<string name="zap_type_anonymous_explainer">Empfänger und die Öffentlichkeit wissen nicht, wer die Zahlung gesendet hat</string>
|
||||
<string name="zap_type_nonzap">Keine Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Keine Spur in Nostr, nur in Lightning</string>
|
||||
<string name="file_server">Dateiserver</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress oder @Benutzer</string>
|
||||
@@ -321,6 +328,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="no">Nein</string>
|
||||
<string name="follow_list_selection">Folgen-Liste</string>
|
||||
<string name="follow_list_kind3follows">Alle Folgen</string>
|
||||
<string name="follow_list_global">Weltweit</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown"> ## Über Tor mit Orbot verbinden
|
||||
\n\n1. Installiere [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Starte Orbot
|
||||
@@ -356,6 +364,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows">Warnung bei Meldungen von deinen Abonnements</string>
|
||||
<string name="new_reaction_symbol">Neues Reaktionssymbol</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">Keine Reaktionstypen ausgewählt. Lange drücken, um zu ändern</string>
|
||||
<string name="zapraiser">Zap-Sammlung</string>
|
||||
<string name="zapraiser_explainer">Fügt einen Zielbetrag in Sats für diesen Beitrag hinzu. Unterstützende Clients können dies als Fortschrittsbalken anzeigen, um Spenden zu fördern</string>
|
||||
<string name="zapraiser_target_amount_in_sats">Zielbetrag in Sats</string>
|
||||
<string name="sats_to_complete">Zapraiser bei %1$s. %2$s Sats bis zum Ziel</string>
|
||||
@@ -443,4 +452,21 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="automatically_play_videos_description">Videos und GIFs automatisch abspielen</string>
|
||||
<string name="automatically_show_url_preview_description">URL-Vorschauen anzeigen</string>
|
||||
<string name="load_image_description">Wann Bilder geladen werden sollen</string>
|
||||
<string name="copy_url_to_clipboard">URL in die Zwischenablage kopieren</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Notiz-ID in die Zwischenablage kopieren</string>
|
||||
<string name="created_at">Erstellt am</string>
|
||||
<string name="rules">Regeln</string>
|
||||
<string name="status_update">Status aktualisieren</string>
|
||||
<string name="lightning_wallets_not_found">Fehler beim Verarbeiten der Fehlermeldung</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Die Abstimmungen werden nach der Höhe des Zaps gewichtet. Sie können einen Mindestbetrag festlegen, um Spam zu verhindern, und einen Höchstbetrag, um zu verhindern, dass große Zapper die Abstimmung dominieren. Verwenden Sie denselben Betrag in beiden Feldern, um sicherzustellen, dass jeder Stimme der gleiche Wert zukommt. Lassen Sie es leer, um jeden Betrag zu akzeptieren.</string>
|
||||
<string name="error_dialog_zap_error">Zap konnte nicht gesendet werden</string>
|
||||
<string name="error_dialog_talk_to_user">Mit dem Benutzer kommunizieren</string>
|
||||
<string name="relay_information_document_error_assemble_url">Konnte %1$s nicht erreichen: %2$s</string>
|
||||
<string name="relay_information_document_error_reach_server">Konnte %1$s nicht erreichen: %2$s</string>
|
||||
<string name="relay_information_document_error_parse_result">Konnte das Ergebnis von %1$s nicht verarbeiten: %2$s</string>
|
||||
<string name="relay_information_document_error_http_status">%1$s fehlgeschlagen mit Code %2$s</string>
|
||||
<string name="active_for">Aktiv für: </string>
|
||||
<string name="active_for_home">Startseite</string>
|
||||
<string name="active_for_msg">Nachrichten</string>
|
||||
<string name="active_for_search">Suche</string>
|
||||
</resources>
|
||||
|
||||
@@ -474,4 +474,14 @@
|
||||
<string name="error_dialog_zap_error">Zapin lähettäminen ei onnistunut</string>
|
||||
<string name="error_dialog_talk_to_user">Viesti käyttäjälle</string>
|
||||
<string name="error_dialog_button_ok">Ok</string>
|
||||
<string name="relay_information_document_error_assemble_url">Kohdetta %1$s ei tavoitettu: %2$s</string>
|
||||
<string name="relay_information_document_error_reach_server">Kohdetta %1$s ei tavoitettu: %2$s</string>
|
||||
<string name="relay_information_document_error_parse_result">Tuloksen jäsentäminen epäonnistui %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_http_status">%1$s epäonnistui koodilla %2$s</string>
|
||||
<string name="active_for">Aktiivinen kohteelle: </string>
|
||||
<string name="active_for_home">Koti</string>
|
||||
<string name="active_for_msg">YVt</string>
|
||||
<string name="active_for_chats">Keskustelut</string>
|
||||
<string name="active_for_global">Yleinen</string>
|
||||
<string name="active_for_search">Haku</string>
|
||||
</resources>
|
||||
|
||||
@@ -474,4 +474,26 @@
|
||||
<string name="error_dialog_zap_error">Impossible d\'envoyer un zap</string>
|
||||
<string name="error_dialog_talk_to_user">Contacter l\'Utilisateur</string>
|
||||
<string name="error_dialog_button_ok">Ok</string>
|
||||
<string name="relay_information_document_error_assemble_url">Impossible d\'atteindre %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_reach_server">Impossible d\'atteindre %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_parse_result">Impossible d\'analyser le résultat de %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_http_status">%1$s a échoué avec le code %2$s</string>
|
||||
<string name="active_for">Actif pour : </string>
|
||||
<string name="active_for_home">Accueil</string>
|
||||
<string name="active_for_msg">MPs</string>
|
||||
<string name="active_for_chats">Salons</string>
|
||||
<string name="active_for_global">Général</string>
|
||||
<string name="active_for_search">Rechercher</string>
|
||||
<string name="zap_split_title">Diviser et Transférer des Zaps</string>
|
||||
<string name="zap_split_explainer">Les clients supportés vont diviser et transférer les zaps aux utilisateurs ajoutés ici au lieu du vôtre</string>
|
||||
<string name="zap_split_serarch_and_add_user">Rechercher et Ajouter un Utilisateur</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Nom d\'utilisateur ou nom affiché</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur %1$s n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string>
|
||||
<string name="zap_split_weight">Pourcentage</string>
|
||||
<string name="zap_split_weight_placeholder">25</string>
|
||||
<string name="splitting_zaps_with">Diviser les zaps avec</string>
|
||||
<string name="forwarding_zaps_to">Transférer les zaps à</string>
|
||||
<string name="lightning_wallets_not_found2">Portefeuilles Lightning introuvables</string>
|
||||
<string name="paid">Payé</string>
|
||||
<string name="wallet_number">Portefeuille %1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -468,10 +468,20 @@
|
||||
<string name="copy_the_note_id_to_the_clipboard">A bejegyzésazonosító vágólapra másolása</string>
|
||||
<string name="created_at">Létrehozva</string>
|
||||
<string name="rules">Szabályok</string>
|
||||
<string name="status_update">Állapotod változtatása</string>
|
||||
<string name="status_update">Állapotod megjelenítése</string>
|
||||
<string name="lightning_wallets_not_found">Hiba a hibaüzenet elemzésekor</string>
|
||||
<string name="poll_zap_value_min_max_explainer">A szavazatokat a Zap-ek összegével súlyozzuk. Beállíthatsz egy minimális összeget, hogy a kéretlen leveleket elkerüld, és egy maximális összeget annak elkerülésére, hogy a szavazás feletti irányítást a nagy Zapperek vegyék át. Mindkét mezőben ugyanazt az összeget használd, hogy minden szavazat azonos értéket kapjon. Bármilyen összeg elfogadásához, hagyjd üresen.</string>
|
||||
<string name="error_dialog_zap_error">Zap küldése nem sikerült</string>
|
||||
<string name="error_dialog_talk_to_user">Üzenet a Felhasználónak</string>
|
||||
<string name="error_dialog_button_ok">Ok</string>
|
||||
<string name="relay_information_document_error_assemble_url">Nem sikerült elérni %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_reach_server">Nem sikerült elérni %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_parse_result">Nem sikerült az eredmény elemzése a %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_http_status">%1$s hiba a következő kóddal %2$s</string>
|
||||
<string name="active_for">Aktív: </string>
|
||||
<string name="active_for_home">Főoldal</string>
|
||||
<string name="active_for_msg">PÜk</string>
|
||||
<string name="active_for_chats">Chat</string>
|
||||
<string name="active_for_global">Globális</string>
|
||||
<string name="active_for_search">Keresés</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="point_to_the_qr_code">Arahkan ke Kode QR</string>
|
||||
<string name="show_qr">Tampilkan QR</string>
|
||||
<string name="profile_image">Foto Profil</string>
|
||||
<string name="scan_qr">Pindai QR</string>
|
||||
<string name="show_anyway">Tampilkan Saja</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Postingan dilaporkan oleh</string>
|
||||
<string name="post_not_found">Data sedang dimuat atau tidak dapat ditemukan di daftar relai Anda</string>
|
||||
<string name="channel_image">Gambar Kanal</string>
|
||||
<string name="referenced_event_not_found">Data yang direferensikan tidak ditemukan</string>
|
||||
<string name="could_not_decrypt_the_message">Tidak dapat mendeskripsikan pesan</string>
|
||||
<string name="group_picture">Gambar Grup</string>
|
||||
<string name="explicit_content">Konten Eksplisit</string>
|
||||
<string name="spam">Spam</string>
|
||||
<string name="impersonation">Peniruan/penipuan</string>
|
||||
<string name="illegal_behavior">Perilaku Ilegal</string>
|
||||
<string name="unknown">Tidak dikenal</string>
|
||||
<string name="relay_icon">Ikon Relai</string>
|
||||
<string name="unknown_author">Penulis Tidak Dikenal</string>
|
||||
<string name="copy_text">Salin Teks</string>
|
||||
<string name="copy_user_pubkey">Salin ID Penulis (Author ID)</string>
|
||||
<string name="copy_note_id">Salin ID Tulisan (Note ID)</string>
|
||||
<string name="broadcast">Siarkan</string>
|
||||
<string name="request_deletion">Minta Penghapusan</string>
|
||||
<string name="block_report">Blokir / Laporkan</string>
|
||||
<string name="block_hide_user"><![CDATA[Blokir & Sembunyikan Pengguna]]></string>
|
||||
<string name="report_spam_scam">Laporkan Spam / Penipuan</string>
|
||||
<string name="report_impersonation">Laporkan Peniruan</string>
|
||||
<string name="report_explicit_content">Laporkan Konten Eksplisit</string>
|
||||
<string name="report_illegal_behaviour">Laporkan Perilaku Ilegal</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">Masuk dengan Kunci pribadi (nsec) untuk dapat membalas tulisan</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Masuk dengan Kunci pribadi (nsec) untuk dapat mempromosikan tulisan</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">Masuk dengan Kunci pribadi (nsec) untuk dapat menyukai tulisan</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Tidak Ada Pengaturan Jumlah Zap. Tekan dan tahan tombol untuk mengubah</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Masuk dengan Kunci pribadi (nsec) untuk dapat mengirim Zaps</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">Masuk dengan Kunci pribadi (nsec) untuk dapat mengikuti</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Masuk dengan Kunci pribadi (nsec) untuk berhenti mengikuti</string>
|
||||
<string name="zaps">Zaps</string>
|
||||
<string name="view_count">Jumlah pengamat</string>
|
||||
<string name="boost">Promosi</string>
|
||||
<string name="boosted">dipromosikan</string>
|
||||
<string name="quote">Kutipan</string>
|
||||
<string name="new_amount_in_sats">Jumlah Baru dalam Sats</string>
|
||||
<string name="add">Tambah</string>
|
||||
<string name="replying_to">"membalas kepada "</string>
|
||||
<string name="and">" dan "</string>
|
||||
<string name="in_channel">dalam kanal</string>
|
||||
<string name="profile_banner">Spanduk Profil</string>
|
||||
<string name="payment_successful">Pembayaran Berhasil</string>
|
||||
<string name="error_parsing_error_message">Gagal menguraikan pesan kesalahan</string>
|
||||
<string name="following">" Mengikuti"</string>
|
||||
<string name="followers">" Pengikut"</string>
|
||||
<string name="profile">Profil</string>
|
||||
<string name="security_filters">Filter Keamanan</string>
|
||||
<string name="log_out">Keluar</string>
|
||||
<string name="show_more">Tampilkan lebih banyak</string>
|
||||
<string name="lightning_invoice">Faktur Tagihan Lightning</string>
|
||||
<string name="pay">Bayar</string>
|
||||
<string name="lightning_tips">Lightning Tip</string>
|
||||
<string name="note_to_receiver">Catatan ke Penerima</string>
|
||||
<string name="thank_you_so_much">Terima kasih banyak!</string>
|
||||
<string name="amount_in_sats">Jumlah dalam Sats</string>
|
||||
<string name="send_sats">Kirim Sats</string>
|
||||
<string name="error_parsing_preview_for">Gagal menampilkan pratinjau untuk %1$s : %2$s</string>
|
||||
<string name="preview_card_image_for">Pratinjau Gambar Kartu untuk %1$s</string>
|
||||
<string name="new_channel">Kanal Baru</string>
|
||||
<string name="channel_name">Nama Kanal</string>
|
||||
<string name="my_awesome_group">Grupku yang Hebat</string>
|
||||
<string name="picture_url">Alamat Url Gambar</string>
|
||||
<string name="description">Deskripsi</string>
|
||||
<string name="about_us">"Tentang kami.. "</string>
|
||||
<string name="what_s_on_your_mind">Apa yang anda pikirkan?</string>
|
||||
<string name="post">Kirim</string>
|
||||
<string name="save">Simpan</string>
|
||||
<string name="create">Buat</string>
|
||||
<string name="cancel">Batal</string>
|
||||
<string name="failed_to_upload_the_image">Gagal mengunggah gambar</string>
|
||||
<string name="relay_address">Alamat Relai</string>
|
||||
<string name="posts">Kiriman</string>
|
||||
<string name="bytes">Bytes</string>
|
||||
<string name="errors">Errors</string>
|
||||
<string name="home_feed">Beranda</string>
|
||||
<string name="private_message_feed">Area Pesan Pribadi</string>
|
||||
<string name="public_chat_feed">Area Percakapan Publik</string>
|
||||
<string name="global_feed">Area Global</string>
|
||||
<string name="search_feed">Area Pencarian</string>
|
||||
<string name="add_a_relay">Tambah Relai</string>
|
||||
<string name="display_name">Nama ditampilkan</string>
|
||||
<string name="my_display_name">Namaku yg ditampilkan</string>
|
||||
<string name="username">Nama pengguna</string>
|
||||
<string name="my_username">Nama penggunaku</string>
|
||||
<string name="about_me">Tentang saya</string>
|
||||
<string name="avatar_url">URL Avatar</string>
|
||||
<string name="banner_url">"URL Spanduk/Banner "</string>
|
||||
<string name="website_url">URL Situs Web</string>
|
||||
<string name="ln_address">Alamat LN</string>
|
||||
<string name="ln_url_outdated">LN URL (versi lama)</string>
|
||||
<string name="image_saved_to_the_gallery">Gambar disimpan ke galeri</string>
|
||||
<string name="failed_to_save_the_image">Gagal menyimpan gambar</string>
|
||||
<string name="upload_image">Unggah Gambar</string>
|
||||
<string name="uploading">Mengunggah…</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Pengguna tidak memiliki alamat lightning (LN) yang diatur untuk menerima sats</string>
|
||||
<string name="reply_here">"balas di sini.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Menyalin ID Catatan ke papan klip untuk dibagikan di Nostr</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">Salin ID Kanal (Catatan) ke Papan Klip</string>
|
||||
<string name="edits_the_channel_metadata">Ubah Metadata Kanal</string>
|
||||
<string name="join">Gabung</string>
|
||||
<string name="known">Dikenali</string>
|
||||
<string name="new_requests">Permintaan Baru</string>
|
||||
<string name="blocked_users">Pengguna diblokir</string>
|
||||
<string name="new_threads">Utas Baru</string>
|
||||
<string name="conversations">Percakapan</string>
|
||||
<string name="notes">Catatan</string>
|
||||
<string name="replies">Balasan</string>
|
||||
<string name="follows">Mengikuti</string>
|
||||
<string name="reports">Laporan</string>
|
||||
<string name="more_options">Lebih banyak pilihan</string>
|
||||
<string name="relays">" Relai"</string>
|
||||
<string name="website">Situs Web</string>
|
||||
<string name="lightning_address">Alamat Lightning</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Salin Nsec ID (kata sandi anda) ke papan klip sebagai cadangan</string>
|
||||
<string name="copy_private_key_to_the_clipboard">Salin Kunci Privat ke papan klip</string>
|
||||
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Salin Kunci Publik ke papan klip untuk dibagikan</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">Salin Kunci Publik (NPub) ke papan klip</string>
|
||||
<string name="send_a_direct_message">Kirim Pesan Langsung</string>
|
||||
<string name="edits_the_user_s_metadata">Ubah Metadata Pengguna</string>
|
||||
<string name="follow">Ikuti</string>
|
||||
<string name="follow_back">Ikuti balik</string>
|
||||
<string name="unblock">Batalkan blokir</string>
|
||||
<string name="copy_user_id">Salin ID Pengguna</string>
|
||||
<string name="unblock_user">Batalkan blokir pengguna</string>
|
||||
<string name="npub_hex_username">npub, nama pengguna, teks</string>
|
||||
<string name="clear">Bersihkan</string>
|
||||
<string name="app_logo">Logo Aplikasi</string>
|
||||
<string name="nsec_npub_hex_private_key">nsec.. or npub..</string>
|
||||
<string name="show_password">Tampilkan Kata Sandi</string>
|
||||
<string name="hide_password">Sembunyikan Kata Sandi</string>
|
||||
<string name="invalid_key">Kunci tidak sah</string>
|
||||
<string name="i_accept_the">Saya menerima</string>
|
||||
<string name="terms_of_use">syarat penggunaan</string>
|
||||
<string name="acceptance_of_terms_is_required">Penerimaan persyaratan diperlukan</string>
|
||||
<string name="key_is_required">Kunci diperlukan</string>
|
||||
<string name="login">Masuk</string>
|
||||
<string name="generate_a_new_key">Buat kunci baru</string>
|
||||
<string name="loading_feed">Memuat data umpan</string>
|
||||
<string name="error_loading_replies">"Gagal memuat balasan: "</string>
|
||||
<string name="try_again">Coba lagi</string>
|
||||
<string name="feed_is_empty">Data umpan kosong.</string>
|
||||
<string name="refresh">Perbarui</string>
|
||||
<string name="created">dibuat</string>
|
||||
<string name="with_description_of">"dengan deskripsi "</string>
|
||||
<string name="and_picture">dan gambar</string>
|
||||
<string name="changed_chat_name_to">mengubah nama percakapan menjadi</string>
|
||||
<string name="description_to">deskripsi untuk</string>
|
||||
<string name="and_picture_to">dan gambar untuk</string>
|
||||
<string name="leave">Tinggalkan</string>
|
||||
<string name="unfollow">Berhenti mengikuti</string>
|
||||
<string name="channel_created">Kanal dibuat</string>
|
||||
<string name="channel_information_changed_to">Informasi kanal diubah menjadi</string>
|
||||
<string name="public_chat">Percakapan Publik</string>
|
||||
<string name="posts_received">kiriman diterima</string>
|
||||
<string name="remove">Hapus</string>
|
||||
<string name="translations_auto">Otomatis</string>
|
||||
<string name="translations_translated_from">diterjemahkan dari</string>
|
||||
<string name="translations_to">ke</string>
|
||||
<string name="translations_show_in_lang_first">Tampilkan dalam %1$s (prioritas)</string>
|
||||
<string name="translations_always_translate_to_lang">Selalu terjemahkan ke %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Jangan terjemahkan dari %1$s</string>
|
||||
<string name="nip_05">Alamat Nostr</string>
|
||||
<string name="never">tidak pernah</string>
|
||||
<string name="now">sekarang</string>
|
||||
<string name="h">jam</string>
|
||||
<string name="m">menit</string>
|
||||
<string name="d">hari</string>
|
||||
<string name="nudity">Ketelanjangan</string>
|
||||
<string name="profanity_hateful_speech">Ujaran kotor/kebencian</string>
|
||||
<string name="report_hateful_speech">Laporkan ujaran kebencian</string>
|
||||
<string name="report_nudity_porn">Laporkan Ketelanjangan / Pornografi</string>
|
||||
<string name="others">lainnya</string>
|
||||
<string name="mark_all_known_as_read">Tandai semua yg dikenal telah dibaca</string>
|
||||
<string name="mark_all_new_as_read">Tandai pesan baru telah dibaca</string>
|
||||
<string name="mark_all_as_read">Tandai semua telah dibaca</string>
|
||||
<string name="backup_keys">Cadangkan Kunci</string>
|
||||
<string name="account_backup_tips_md" tools:ignore="Typos">"\n ## Tip Pencadangan dan Keamanan Utama\n \n\nAkun Anda diamankan dengan kunci rahasia. Kuncinya adalah string acak panjang yang dimulai dengan **nsec1**.. Siapa pun yang memiliki akses ke kunci rahasia Anda dapat mempublikasikan konten menggunakan identitas Anda.\n \n\n- **Jangan** letakkan kunci rahasia Anda di situs web atau perangkat lunak apa pun yang tidak Anda percayai.\n \n- Pengembang Aplikasi Amethyst **tidak akan pernah** meminta kunci rahasia Anda.\n \n- **Pastikan** simpan cadangan aman kunci rahasia Anda untuk pemulihan akun. Kami merekomendasikan menggunakan aplikasi khusus pengelola kata sandi.\n "</string>
|
||||
<string name="secret_key_copied_to_clipboard">Kunci rahasia (nsec) disalin ke clipboard</string>
|
||||
<string name="copy_my_secret_key">Salin kunci rahasia saya</string>
|
||||
<string name="biometric_authentication_failed">Autentikasi gagal</string>
|
||||
<string name="biometric_error">Kesalahan</string>
|
||||
<string name="badge_created_by">Dibuat oleh %1$s</string>
|
||||
<string name="badge_award_image_for">Gambar penghargaan lencana untuk %1$s</string>
|
||||
<string name="new_badge_award_notif">Anda Menerima Penghargaan Lencana baru</string>
|
||||
<string name="award_granted_to">Penghargaan lencana diberikan kepada</string>
|
||||
<string name="copied_note_text_to_clipboard">Teks catatan disalin ke papan klip</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">\@npub Penulis disalin ke papan klip</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Note ID (@note1) disalin ke papan klip</string>
|
||||
<string name="select_text_dialog_top">Pilih Teks</string>
|
||||
<string name="private_conversation_notification"><Gagal mendeskripsikan pesan pribadi>\n\nAnda dikutip dalam percakapan pribadi/terenkripsi antara %1$s dan %2$s.</string>
|
||||
<string name="account_switch_add_account_dialog_title">Tambah Akun Baru</string>
|
||||
<string name="drawer_accounts">Akun</string>
|
||||
<string name="account_switch_select_account">Pilih Akun</string>
|
||||
<string name="account_switch_add_account_btn">Tambah Akun Baru</string>
|
||||
<string name="account_switch_active_account">Akun Aktif</string>
|
||||
<string name="account_switch_has_private_key">Memiliki kunci pribadi</string>
|
||||
<string name="account_switch_pubkey_only">Baca saja, tidak ada kunci pribadi</string>
|
||||
<string name="back">Kembali</string>
|
||||
<string name="quick_action_select">Pilih</string>
|
||||
<string name="quick_action_share_browser_link">Bagikan Tauatan ke Peramban</string>
|
||||
<string name="quick_action_share">Bagikan</string>
|
||||
<string name="quick_action_copy_user_id">ID Penulis</string>
|
||||
<string name="quick_action_copy_note_id">ID Catatan</string>
|
||||
<string name="quick_action_copy_text">Salin Teks</string>
|
||||
<string name="quick_action_delete">Hapus</string>
|
||||
<string name="quick_action_unfollow">Berhenti mengikuti</string>
|
||||
<string name="quick_action_follow">Ikuti</string>
|
||||
<string name="quick_action_request_deletion_alert_title">Meminta Penghapusan</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Amethyst akan meminta agar catatan Anda dihapus dari relai yang saat ini Anda sambungkan. Tidak ada jaminan bahwa catatan Anda akan dihapus secara permanen dari relai tersebut, atau dari relai lain tempat catatan itu disimpan.</string>
|
||||
<string name="quick_action_block_dialog_btn">Blokir</string>
|
||||
<string name="quick_action_delete_dialog_btn">Hapus</string>
|
||||
<string name="quick_action_block">Blokir</string>
|
||||
<string name="quick_action_report">Laporkan</string>
|
||||
<string name="quick_action_delete_button">Hapus</string>
|
||||
<string name="quick_action_dont_show_again_button">Jangan tampilkan lagi</string>
|
||||
<string name="report_dialog_spam">Spam atau penipuan</string>
|
||||
<string name="report_dialog_profanity">Perilaku tidak senonoh atau penuh kebencian</string>
|
||||
<string name="report_dialog_impersonation">Peniruan identitas yang berbahaya</string>
|
||||
<string name="report_dialog_nudity">Konten ketelanjangan atau grafis</string>
|
||||
<string name="report_dialog_illegal">Perilaku Ilegal</string>
|
||||
<string name="report_dialog_blocking_a_user">Memblokir pengguna akan menyembunyikan konten mereka di aplikasi Anda. Catatan Anda masih dapat dilihat oleh publik, termasuk oleh orang yang Anda blokir. Pengguna yang diblokir terdaftar di layar Filter Keamanan.</string>
|
||||
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blokir & Sembuniykan Pengguna]]></string>
|
||||
<string name="report_dialog_report_btn">Laporkan Penyalahgunaan</string>
|
||||
<string name="report_dialog_reminder_public">Semua laporan yang diposting akan terlihat oleh publik.</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">Secara opsional, berikan konteks tambahan tentang laporan Anda…</string>
|
||||
<string name="report_dialog_additional_reason_label">Konteks Tambahan</string>
|
||||
<string name="report_dialog_select_reason_label">Alasan</string>
|
||||
<string name="report_dialog_select_reason_placeholder">Pilih alasan…</string>
|
||||
<string name="report_dialog_post_report_btn">Kirim Laporan</string>
|
||||
<string name="report_dialog_title">Blokir dan Laporkan</string>
|
||||
<string name="block_only">Blokir</string>
|
||||
|
||||
<string name="bookmarks">Penanda</string>
|
||||
<string name="private_bookmarks">Penanda Pribadi</string>
|
||||
<string name="public_bookmarks">Penanda Publik</string>
|
||||
<string name="add_to_private_bookmarks">Tambahkan Penanda Pribadi</string>
|
||||
<string name="add_to_public_bookmarks">Tambahkan Penanda Publik</string>
|
||||
<string name="remove_from_private_bookmarks">Hapus dari Penanda Pribadi</string>
|
||||
<string name="remove_from_public_bookmarks">Hapus dari Penanda Publik</string>
|
||||
|
||||
<string name="wallet_connect_service">Layanan Wallet Connect</string>
|
||||
<string name="wallet_connect_service_explainer">Mengijinkan Kunci Rahasia Nostr untuk membayar zaps tanpa meninggalkan aplikasi. Jaga kunci tetap aman dan gunakan relai pribadi jika memungkinkan</string>
|
||||
<string name="wallet_connect_service_pubkey">Kunci Publik Wallet Connect</string>
|
||||
<string name="wallet_connect_service_relay">Relai Wallet Connect</string>
|
||||
<string name="wallet_connect_service_secret">Kunci Rahasia Wallet Connect</string>
|
||||
<string name="wallet_connect_service_show_secret">Tampilkan kunci rahasia</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / hex (kunci pribadi)</string>
|
||||
|
||||
<string name="pledge_amount_in_sats">Jumlah Dijanjikan dalam Sats</string>
|
||||
<string name="post_poll">Kirim Pemungutan Suara</string>
|
||||
<string name="poll_heading_required">Bidang yang wajib diisi:</string>
|
||||
<string name="poll_zap_recipients">Penerima Zap</string>
|
||||
<string name="poll_primary_description">Deskripsi Utama Pemungutan Suara…</string>
|
||||
<string name="poll_option_index">Pilihan %s</string>
|
||||
<string name="poll_option_description">Deskripsi pilihan pemungutan suara</string>
|
||||
<string name="poll_heading_optional">Bidang pilihan (tidak wajib):</string>
|
||||
<string name="poll_zap_value_min">Zap terendah</string>
|
||||
<string name="poll_zap_value_max">Zap tertinggi</string>
|
||||
<string name="poll_consensus_threshold">Konsensus</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time">Berakhir pada</string>
|
||||
<string name="poll_closing_time_days">hari</string>
|
||||
<string name="poll_is_closed">Pemungutan suara ditutup untuk voting baru</string>
|
||||
<string name="poll_zap_amount">Jumlah Zap</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">Hanya satu suara per pengguna yang diperbolehkan pada jenis pemungutan suara ini</string>
|
||||
|
||||
<string name="looking_for_event">Mencari data event %1$s</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message">Tambah pesan publik</string>
|
||||
<string name="custom_zaps_add_a_message_private">Tambah pesan pribadi</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">Tambah pesan faktur tagihan</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message_example">Terima kasih atas semua pekerjaan Anda!</string>
|
||||
|
||||
<string name="lightning_create_and_add_invoice">Buat dan Tambah</string>
|
||||
<string name="poll_author_no_vote">Pembuat Pemungutan Suara tidak dapat memberikan suara dalam pemungutan suara sendiri.</string>
|
||||
|
||||
<string name="hash_verification_passed">Konten ini sama sejak kiriman tersebut dibuat</string>
|
||||
<string name="hash_verification_failed">Konten ini telah berubah. Penulis mungkin tidak melihat atau menyetujui perubahan tersebut</string>
|
||||
|
||||
<string name="content_description_add_image">Tambah Gambar</string>
|
||||
<string name="content_description_add_video">Tambah Video</string>
|
||||
<string name="content_description_add_document">Tambah Dokumen</string>
|
||||
|
||||
<string name="add_content">Tambahkan ke Pesan</string>
|
||||
<string name="content_description">Deskripsi Konten</string>
|
||||
<string name="content_description_example">Perahu berwarna biru di pantai berpasir putih saat matahari terbenam</string>
|
||||
|
||||
<string name="zap_type">Tipe Zap</string>
|
||||
<string name="zap_type_explainer">Tipe Zap untuk semua pilihan</string>
|
||||
|
||||
<string name="zap_type_public">Publik</string>
|
||||
<string name="zap_type_public_explainer">Semua orang dapat melihat transaksi dan pesannya</string>
|
||||
|
||||
<string name="zap_type_private">Pribadi</string>
|
||||
<string name="zap_type_private_explainer">Pengirim dan Penerima dapat saling melihat dan membaca pesan</string>
|
||||
|
||||
<string name="zap_type_anonymous">Anonim</string>
|
||||
<string name="zap_type_anonymous_explainer">Penerima dan semua orang tidak dapat mengetahui siapa pengirim pembayaran</string>
|
||||
|
||||
<string name="zap_type_nonzap">Bukan Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Tidak ada jejak di Nostr, hanya di Lightning Network</string>
|
||||
|
||||
|
||||
<string name="file_server">Server Berkas</string>
|
||||
<string name="zap_forward_lnAddress">Alamat LN atau @Pengguna</string>
|
||||
|
||||
<string name="upload_server_imgur">imgur.com - terpercaya</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur dapat memodifikasi berkas</string>
|
||||
|
||||
<string name="upload_server_nostrimg">nostrimg.com - terpercaya</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg dapat memodifikasi berkas</string>
|
||||
|
||||
<string name="upload_server_nostrbuild">nostr.build - terpercaya</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build dapat memodifikasi berkas</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev - terpercaya</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev dapat memodifikasi berkas</string>
|
||||
|
||||
<string name="upload_server_nostrcheckme">nostrcheck.me - terpercaya</string>
|
||||
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me dapat memodifikasi berkas</string>
|
||||
|
||||
|
||||
<string name="upload_server_imgur_nip94">Imgur yg dapat diverifikasi (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Periksa jika Imgur mengubah berkas. NIP baru: aplikasi Nostr lain mungkin belum mendukung</string>
|
||||
|
||||
<string name="upload_server_nostrimg_nip94">NostrImg yg dapat diverifikasi (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Periksa jika NostrImg mengubah berkas. NIP baru: aplikasi Nostr lain mungkin belum mendukung</string>
|
||||
|
||||
<string name="upload_server_nostrbuild_nip94">Nostr.build yg dapat diverifikasi (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Periksa jika Nostr.build mengubah berkas. NIP baru: aplikasi Nostr lain mungkin belum mendukung</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev_nip94">Nostrfiles.dev yg dapat diverifikasi (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Periksa jika Nostrfiles.dev mengubah berkas. NIP baru: aplikasi Nostr lain mungkin belum mendukung</string>
|
||||
|
||||
<string name="upload_server_nostrcheckme_nip94">Nostrcheck.me yg dapat diverifikasi (NIP-94)</string>
|
||||
<string name="upload_server_nostrcheckme_nip94_explainer">Periksa jika Nostrcheck.me mengubah berkas. NIP baru: aplikasi Nostr lain mungkin belum mendukung</string>
|
||||
|
||||
<string name="upload_server_relays_nip95">Relai anda (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Berkas disimpan di relai anda. NIP baru: periksa apakah relai mendukung fitur</string>
|
||||
|
||||
<string name="connect_via_tor_short">Pengaturan Tor/Orbot</string>
|
||||
<string name="connect_via_tor">Hubungkan melalui pengaturan Orbot Anda</string>
|
||||
|
||||
<string name="do_you_really_want_to_disable_tor_title">Putuskan sambungan dari Orbot/Tor Anda?</string>
|
||||
<string name="do_you_really_want_to_disable_tor_text">Data Anda akan segera ditransfer di jaringan reguler (bukan TOR)</string>
|
||||
<string name="yes">Ya</string>
|
||||
<string name="no">Tidak</string>
|
||||
|
||||
|
||||
<string name="follow_list_selection">Daftar yang Diikuti</string>
|
||||
<string name="follow_list_kind3follows">Semua yang diikuti</string>
|
||||
<string name="follow_list_global">Global</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown">"\n ## Terhubung melalui Tor menggunakan Orbot\n \n\n1. Pasang [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)\n \n2. Jalankan Orbot\n \n3. Di dalam Orbot, periksa nomor port Socks. Secara bawaan menggunakan nomor 9050\n \n4. Jika diperlukan, ubah nomor port Orbot\n \n5. Atur nomor port Socks pada layar ini\n \n6. Tekan tombol Aktifkan untuk menggunakan Orbot sebagai proxy\n "</string>
|
||||
<string name="orbot_socks_port">Port Orbot Socks</string>
|
||||
<string name="invalid_port_number">Nomor port tidak sah</string>
|
||||
<string name="use_orbot">Gunakan Orbot</string>
|
||||
<string name="disconnect_from_your_orbot_setup">Putuskan Tor/Orbot</string>
|
||||
|
||||
<string name="app_notification_dms_channel_name">Pesan Pribadi</string>
|
||||
<string name="app_notification_dms_channel_description">Memberi tahu Anda ketika pesan pribadi tiba</string>
|
||||
|
||||
<string name="app_notification_zaps_channel_name">Zaps diterima</string>
|
||||
<string name="app_notification_zaps_channel_description">Memberi tahu Anda ketika seseorang mengirimkan anda zap</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Dari %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">untuk %1$s</string>
|
||||
|
||||
<string name="reply_notify">"Beritahu: "</string>
|
||||
|
||||
<string name="channel_list_join_conversation">Gabung Percakapan</string>
|
||||
<string name="channel_list_user_or_group_id">ID Pengguna atau Grup</string>
|
||||
<string name="channel_list_user_or_group_id_demo">npub, nevent atau kode heksa</string>
|
||||
<string name="channel_list_create_channel">Buat</string>
|
||||
<string name="channel_list_join_channel">Gabung</string>
|
||||
|
||||
<string name="today">Hari ini</string>
|
||||
|
||||
<string name="content_warning">Peringatan konten</string>
|
||||
<string name="content_warning_explanation">Postingan ini berisi konten sensitif yang mungkin dianggap menyinggung atau mengganggu oleh sebagian orang</string>
|
||||
<string name="content_warning_hide_all_sensitive_content">Selalu sembunyikan konten sensitif</string>
|
||||
<string name="content_warning_show_all_sensitive_content">Selalu tampilkan konten sensitif</string>
|
||||
<string name="content_warning_see_warnings">Selalu tampilkan peringatan konten</string>
|
||||
|
||||
<string name="recommended_apps">"Rekomendasi: "</string>
|
||||
<string name="filter_spam_from_strangers">Saring spam dari orang asing (tak diikuti)</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows">Peringatkan ketika kiriman memiliki laporan dari pengikut Anda</string>
|
||||
|
||||
<string name="new_reaction_symbol">Simbol Reaksi Baru</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">Tidak ada jenis reaksi yang dipilih. Tekan dan tahan untuk mengubah</string>
|
||||
|
||||
<string name="zapraiser">Galang Dana Zap</string>
|
||||
<string name="zapraiser_explainer">Menambahkan jumlah target sats yang ingin dikumpulkan untuk kiriman ini. Klien yang mendukung mungkin menunjukkan hal ini sebagai indikator kemajuan dalam memberi insentif pada donasi</string>
|
||||
<string name="zapraiser_target_amount_in_sats">Jumlah Target dalam Sats</string>
|
||||
|
||||
<string name="sats_to_complete">Galang Dana Zap mencapai %1$s. %2$s sats untuk memenuhi target</string>
|
||||
<string name="read_from_relay">Baca dari Relai</string>
|
||||
<string name="write_to_relay">Tulis ke Relai</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">Terjadi kesalahan saat mencoba mendapatkan informasi relai %1$s</string>
|
||||
<string name="owner">Pemilik</string>
|
||||
<string name="version">Versi</string>
|
||||
<string name="software">Perangkat Lunak</string>
|
||||
<string name="contact">Kontak</string>
|
||||
<string name="supports">NIP yg didukung</string>
|
||||
<string name="admission_fees">Biaya Pendaftaran</string>
|
||||
<string name="payments_url">Alamat Pembayaran</string>
|
||||
<string name="limitations">Batasan</string>
|
||||
<string name="countries">Negara</string>
|
||||
<string name="languages">Bahasa</string>
|
||||
<string name="tags">Tags</string>
|
||||
<string name="posting_policy">Kebijakan kiriman</string>
|
||||
<string name="message_length">Panjang pesan</string>
|
||||
<string name="subscriptions">Langganan</string>
|
||||
<string name="filters">Penyaringan</string>
|
||||
<string name="subscription_id_length">Panjang ID Subscription</string>
|
||||
<string name="minimum_prefix">Awalan minimal</string>
|
||||
<string name="maximum_event_tags">Jumlah Tags maksimum</string>
|
||||
<string name="content_length">Panjang konten</string>
|
||||
<string name="minimum_pow">PoW terendah</string>
|
||||
<string name="auth">Autentikasi</string>
|
||||
<string name="payment">Pembayaran</string>
|
||||
<string name="cashu">Token Cashu</string>
|
||||
<string name="cashu_redeem">Tukarkan</string>
|
||||
<string name="no_lightning_address_set">Tidak ada alamat Lightning yang diatur</string>
|
||||
<string name="copied_token_to_clipboard">Token disalin ke papan klip</string>
|
||||
|
||||
<string name="live_stream_live_tag">DARING (ON)</string>
|
||||
<string name="live_stream_offline_tag">LURING (OFF)</string>
|
||||
<string name="live_stream_ended_tag">BERAKHIR</string>
|
||||
<string name="live_stream_planned_tag">DIJADWALKAN</string>
|
||||
|
||||
<string name="live_stream_is_offline">Siaran langsung sedang luring</string>
|
||||
<string name="live_stream_has_ended">Siaran langsung berakhir</string>
|
||||
<string name="are_you_sure_you_want_to_log_out">Keluar akan menghapus semua informasi lokal Anda. Pastikan kunci pribadi Anda dicadangkan untuk menghindari kehilangan akun Anda. Apakah Anda ingin melanjutkan?</string>
|
||||
<string name="followed_tags">Tags yg diikuti</string>
|
||||
|
||||
<string name="relay_setup">Relai</string>
|
||||
|
||||
<string name="discover_live">Siaran Langsung</string>
|
||||
<string name="discover_community">Komunitas</string>
|
||||
<string name="discover_chat">Percakapan</string>
|
||||
<string name="community_approved_posts">Kiriman yg disetujui</string>
|
||||
|
||||
<string name="groups_no_descriptor">Grup ini tidak memiliki deskripsi atau aturan. Bicaralah dengan pemiliknya untuk menambahkannya</string>
|
||||
<string name="community_no_descriptor">Komunitas ini tidak memiliki deskripsi. Bicaralah dengan pemiliknya untuk menambahkannya</string>
|
||||
|
||||
<string name="add_sensitive_content_label">Konten Sensitif</string>
|
||||
<string name="add_sensitive_content_description">Menambahkan peringatan konten sensitif sebelum menampilkan konten ini</string>
|
||||
<string name="settings">Pengaturan</string>
|
||||
<string name="connectivity_type_always">Selalu</string>
|
||||
<string name="connectivity_type_wifi_only">Hanya Wifi</string>
|
||||
<string name="connectivity_type_never">Tidak Pernah</string>
|
||||
|
||||
<string name="system">Sistem</string>
|
||||
<string name="light">Terang</string>
|
||||
<string name="dark">Gelap</string>
|
||||
<string name="application_preferences">Pengaturan Aplikasi</string>
|
||||
<string name="language">Bahasa</string>
|
||||
<string name="theme">Tema</string>
|
||||
<string name="automatically_load_images_gifs">Pratinjau Gambar</string>
|
||||
<string name="automatically_play_videos">Pemutaran Video</string>
|
||||
<string name="automatically_show_url_preview">Pratinjau URL</string>
|
||||
<string name="load_image">Muat Gambar</string>
|
||||
|
||||
<string name="spamming_users">Pengirim Spam</string>
|
||||
|
||||
<string name="muted_button">Diredam. Klik untuk bersuara</string>
|
||||
<string name="mute_button">Bersuara. Kilik untuk meredam</string>
|
||||
<string name="search_button">Cari catatan lokal dan jarak jauh</string>
|
||||
|
||||
<string name="nip05_verified">Alamat Nostr diverifikasi</string>
|
||||
<string name="nip05_failed">Alamat Nostr gagal diverifikasi</string>
|
||||
<string name="nip05_checking">Memeriksa Alamat Nostr</string>
|
||||
<string name="select_deselect_all">Pilih / Hapus Semua</string>
|
||||
<string name="default_relays">Bawaan</string>
|
||||
<string name="select_a_relay_to_continue">Pilih Relai untuk melanjutkan</string>
|
||||
|
||||
<string name="zap_forward_title">Teruskan Zaps ke:</string>
|
||||
<string name="zap_forward_explainer">Klien yg mendukung akan meneruskan zaps ke Alamat LN atau Profil Pengguna di bawah ini, bukan ke alamat LN Anda</string>
|
||||
|
||||
<string name="geohash_title">Paparkan Lokasi sebagai</string>
|
||||
<string name="geohash_explainer">Menambahkan Geohash lokasi Anda ke postingan. Publik akan mengetahui bahwa Anda berada dalam jarak 5 km (3 mil) dari lokasi saat ini</string>
|
||||
|
||||
<string name="add_sensitive_content_explainer">Menambahkan peringatan konten sensitif sebelum menampilkan konten Anda. Ini ideal untuk konten NSFW apa pun atau konten yang mungkin dianggap menyinggung atau mengganggu oleh sebagian orang</string>
|
||||
|
||||
<string name="new_feature_nip24_might_not_be_available_title">Fitur Baru</string>
|
||||
<string name="new_feature_nip24_might_not_be_available_description">Mengaktifkan mode ini mengharuskan Amethyst mengirim pesan NIP-24 (GiftWrapped, Sealed Direct, dan Group Messages). NIP-24 masih baru dan sebagian besar klien belum menerapkannya. Pastikan penerima menggunakan klien yang kompatibel.</string>
|
||||
<string name="new_feature_nip24_activate">Aktifkan</string>
|
||||
|
||||
<string name="messages_create_public_chat">Publik</string>
|
||||
<string name="messages_new_message">Pribadi</string>
|
||||
<string name="messages_new_message_to">Ke</string>
|
||||
<string name="messages_new_message_subject">Subjek</string>
|
||||
<string name="messages_new_message_subject_caption">Topik percakapan</string>
|
||||
<string name="messages_new_message_to_caption">\@Pengguna1, @Pengguna2, @Pengguna3</string>
|
||||
|
||||
<string name="messages_group_descriptor">Anggota grup ini</string>
|
||||
<string name="messages_new_subject_message">Penjelasan kepada anggota</string>
|
||||
<string name="messages_new_subject_message_placeholder">Mengubah nama untuk tujuan baru.</string>
|
||||
|
||||
<string name="language_description">Untuk Antarmuka Aplikasi</string>
|
||||
<string name="theme_description">Tema Gelap, Terang, atau Sistem</string>
|
||||
<string name="automatically_load_images_gifs_description">Memuat gambar dan GIF secara otomatis</string>
|
||||
<string name="automatically_play_videos_description">Memutar video dan GIF secara otomatis</string>
|
||||
<string name="automatically_show_url_preview_description">Tampilkan pratinjau URL</string>
|
||||
<string name="load_image_description">Kapan memuat gambar</string>
|
||||
|
||||
<string name="copy_url_to_clipboard">Salin URL ke papan klip</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Salin ID Catatan ke papan klip</string>
|
||||
|
||||
<string name="created_at">Dibuat pada</string>
|
||||
<string name="rules">Aturan</string>
|
||||
|
||||
<string name="status_update">Perbaharui statusmu</string>
|
||||
|
||||
<string name="lightning_wallets_not_found">Kesalahan menguraikan pesan kesalahan</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Suara ditimbang berdasarkan jumlah zap. Anda dapat menetapkan jumlah minimum untuk menghindari pembuat spam dan jumlah maksimum untuk menghindari pengirim zap besar mengambil alih pemungutan suara. Gunakan jumlah yang sama di kedua kolom untuk memastikan setiap suara bernilai sama. Biarkan kosong untuk menerima jumlah berapa pun.</string>
|
||||
|
||||
<string name="error_dialog_zap_error">Tidak dapat mengirim zap</string>
|
||||
<string name="error_dialog_talk_to_user">Kirim Pesan ke Pengguna</string>
|
||||
<string name="error_dialog_button_ok">Ok</string>
|
||||
</resources>
|
||||
@@ -1,42 +1,487 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="point_to_the_qr_code">Inquadra il QR Code</string>
|
||||
<string name="point_to_the_qr_code">Inquadra il codice QR</string>
|
||||
<string name="show_qr">Mostra QR</string>
|
||||
<string name="profile_image">Foto profilo</string>
|
||||
<string name="scan_qr">Scansiona QR</string>
|
||||
<string name="show_anyway">Mostra comunque</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Post segnalato da</string>
|
||||
<string name="post_not_found">L\'evento sta caricando o non può essere trovato nei tuoi relay</string>
|
||||
<string name="post_not_found">L\'evento sta caricando o non può essere trovato nei tuoi relè</string>
|
||||
<string name="channel_image">Foto del canale</string>
|
||||
<string name="referenced_event_not_found">L\'evento referenziato non è stato trovato</string>
|
||||
<string name="could_not_decrypt_the_message">Impossibile decrittare il messaggio</string>
|
||||
<string name="referenced_event_not_found">L\'evento di riferimento non è stato trovato</string>
|
||||
<string name="could_not_decrypt_the_message">Impossibile decriptare il messaggio</string>
|
||||
<string name="group_picture">Foto del gruppo</string>
|
||||
<string name="explicit_content">Contenuto esplicito</string>
|
||||
<string name="spam">Spam</string>
|
||||
<string name="impersonation">Impersonazione</string>
|
||||
<string name="impersonation">Impersonificazione</string>
|
||||
<string name="illegal_behavior">Comportamento scorretto</string>
|
||||
<string name="unknown">Sconosciuto</string>
|
||||
<string name="relay_icon">Icona del relay</string>
|
||||
<string name="relay_icon">Icona del relè</string>
|
||||
<string name="unknown_author">Autore sconosciuto</string>
|
||||
<string name="copy_text">Copia testo</string>
|
||||
<string name="copy_user_pubkey">Copia ID autore</string>
|
||||
<string name="copy_note_id">Copia ID nota</string>
|
||||
<string name="copy_user_pubkey">Copia ID dell\'autore</string>
|
||||
<string name="copy_note_id">Copia ID della nota</string>
|
||||
<string name="broadcast">Trasmissione</string>
|
||||
<string name="request_deletion">Richiedi cancellazione</string>
|
||||
<string name="block_report">Blocca / Segnala</string>
|
||||
<string name="block_hide_user"><![CDATA[Blocca e Nascondi Utente]]></string>
|
||||
<string name="report_spam_scam">Segnala Spam / Truffa</string>
|
||||
<string name="report_impersonation">Segnala impersonatore</string>
|
||||
<string name="report_impersonation">Segnala impersonificatore</string>
|
||||
<string name="report_explicit_content">Segnala contenuto esplicito</string>
|
||||
<string name="report_illegal_behaviour">Segnala comportamento inapproriato</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">Accedi con una Chiave Privata per rispondere</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Accedi con una Chiave Privata per ricondividere i post</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">Accedi con una Chiave Privata per poter mettere mi piace ai post</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Nessun importo per gli Zap impostato. Tieni premuto per aggiungerne</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Accedi con una Chiave Privata per poter inviare Zaps</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">Accedi con una Chiave Privata per seguire</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Accedi con una Chiave Privata per smettere di seguire</string>
|
||||
<string name="zaps">Zaps</string>
|
||||
<string name="view_count">Visualizzazioni</string>
|
||||
<string name="boost">Boost</string>
|
||||
<string name="boosted">ricondiviso</string>
|
||||
<string name="report_illegal_behaviour">Segnala comportamento scorretto</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">Accedi con una chiave privata per poter rispondere</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Accedi con una chiave privata per poter condividere i post</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">Accedi con una chiave privata per mettere mi piace ai post</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Nessun ammontare per gli Zap impostato. Tieni premuto per cambiare</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Accedi con una chiave privata per poter inviare gli Zap</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">Accedi con una chiave privata per poter seguire</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Accedi con una chiave privata per poter smettere di seguire</string>
|
||||
<string name="zaps">Zap</string>
|
||||
<string name="view_count">Mostra conteggio</string>
|
||||
<string name="boost">Condividi</string>
|
||||
<string name="boosted">condiviso</string>
|
||||
<string name="quote">Cita</string>
|
||||
<string name="new_amount_in_sats">Nuovo importo in Sats</string>
|
||||
<string name="add">Aggiungi</string>
|
||||
<string name="replying_to">"rispondendo a"</string>
|
||||
<string name="and">" e "</string>
|
||||
<string name="in_channel">"nel canale "</string>
|
||||
<string name="profile_banner">Immagine di copertina</string>
|
||||
<string name="payment_successful">Pagamento effettuato</string>
|
||||
<string name="error_parsing_error_message">Errore analisi messaggio d\'errore</string>
|
||||
<string name="following">" Seguiti"</string>
|
||||
<string name="followers">" Seguaci"</string>
|
||||
<string name="profile">Profilo</string>
|
||||
<string name="security_filters">Filtri di sicurezza</string>
|
||||
<string name="log_out">Esci</string>
|
||||
<string name="show_more">Mostra altro</string>
|
||||
<string name="lightning_invoice">Fattura Lightning</string>
|
||||
<string name="pay">Paga</string>
|
||||
<string name="lightning_tips">Mance Lightning</string>
|
||||
<string name="note_to_receiver">Nota per il Ricevente</string>
|
||||
<string name="thank_you_so_much">Grazie mille!</string>
|
||||
<string name="amount_in_sats">Importo in Sats</string>
|
||||
<string name="send_sats">Invia Sats</string>
|
||||
<string name="error_parsing_preview_for">"Errore nella creazione dell'anteprima di %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Anteprima immagine per %1$s"</string>
|
||||
<string name="new_channel">Nuovo canale</string>
|
||||
<string name="channel_name">Nome canale</string>
|
||||
<string name="my_awesome_group">Il mio fantastico gruppo</string>
|
||||
<string name="picture_url">Url foto</string>
|
||||
<string name="description">Descrizione</string>
|
||||
<string name="about_us">"Su di noi... "</string>
|
||||
<string name="what_s_on_your_mind">A cosa stai pensando?</string>
|
||||
<string name="post">Post</string>
|
||||
<string name="save">Salva</string>
|
||||
<string name="create">Crea</string>
|
||||
<string name="cancel">Cancella</string>
|
||||
<string name="failed_to_upload_the_image">Impossibile caricare l\'immagine</string>
|
||||
<string name="relay_address">Indirizzo Relay</string>
|
||||
<string name="posts">Posts</string>
|
||||
<string name="bytes">Bytes</string>
|
||||
<string name="errors">Errori</string>
|
||||
<string name="home_feed">Home Feed</string>
|
||||
<string name="private_message_feed">Lista Messaggi Privati</string>
|
||||
<string name="public_chat_feed">Lista Chat Pubbliche</string>
|
||||
<string name="global_feed">Feed Globale</string>
|
||||
<string name="search_feed">Risultati Ricerca</string>
|
||||
<string name="add_a_relay">Aggiungi un Relay</string>
|
||||
<string name="display_name">Nome Visualizzato</string>
|
||||
<string name="my_display_name">Il mio nome visualizzato</string>
|
||||
<string name="username">Username</string>
|
||||
<string name="my_username">Il mio nome utente</string>
|
||||
<string name="about_me">Su di me</string>
|
||||
<string name="avatar_url">Immagine del profilo URL</string>
|
||||
<string name="banner_url">Copertina URL</string>
|
||||
<string name="website_url">Sito web URL</string>
|
||||
<string name="ln_address">Indirizzo LN</string>
|
||||
<string name="ln_url_outdated">LN URL (obsoleto)</string>
|
||||
<string name="image_saved_to_the_gallery">Immagine salvata nella galleria</string>
|
||||
<string name="failed_to_save_the_image">Salvataggio dell\'immagine non riuscito</string>
|
||||
<string name="upload_image">Carica immagine</string>
|
||||
<string name="uploading">Caricamento…</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utente non dispone di un indirizzo Lightning per ricevere sats</string>
|
||||
<string name="reply_here">"rispondi qui.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copia l\'ID della nota negli appunti per la condivisione in Nostr</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">Copia l\'ID del canale (Nota) negli appunti</string>
|
||||
<string name="edits_the_channel_metadata">Modifica i metadati del canale</string>
|
||||
<string name="join">Unisciti</string>
|
||||
<string name="known">Conosciuto</string>
|
||||
<string name="new_requests">Nuove richieste</string>
|
||||
<string name="blocked_users">Utenti bloccati</string>
|
||||
<string name="new_threads">Nuove Discussioni</string>
|
||||
<string name="conversations">Conversazioni</string>
|
||||
<string name="notes">Note</string>
|
||||
<string name="replies">Risposte</string>
|
||||
<string name="follows">"Seguiti"</string>
|
||||
<string name="reports">"Segnalazioni"</string>
|
||||
<string name="more_options">Più Opzioni</string>
|
||||
<string name="relays">" Relè"</string>
|
||||
<string name="website">Sito web</string>
|
||||
<string name="lightning_address">Indirizzo Lightning</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Copia l\'ID Nsec (la password) negli appunti per il backup</string>
|
||||
<string name="copy_private_key_to_the_clipboard">Copia la chiave segreta negli appunti</string>
|
||||
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Copia la chiave pubblica negli appunti per la condivisione</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">Copia la chiave pubblica (Npub) negli appunti</string>
|
||||
<string name="send_a_direct_message">Invia un messaggio diretto</string>
|
||||
<string name="edits_the_user_s_metadata">Modifica i metadati dell\'utente</string>
|
||||
<string name="follow">Segui</string>
|
||||
<string name="follow_back">Segui anche tu</string>
|
||||
<string name="unblock">Sblocca</string>
|
||||
<string name="copy_user_id">Copia ID Utente</string>
|
||||
<string name="unblock_user">Sblocca utente</string>
|
||||
<string name="npub_hex_username">"npub, nome utente, testo"</string>
|
||||
<string name="clear">Pulisci</string>
|
||||
<string name="app_logo">Logo dell\'app</string>
|
||||
<string name="nsec_npub_hex_private_key">nsec.. o npub..</string>
|
||||
<string name="show_password">Mostra password</string>
|
||||
<string name="hide_password">Nascondi password</string>
|
||||
<string name="invalid_key">Chiave non valida</string>
|
||||
<string name="i_accept_the">"Accetto il/lo/la/i/gli/le "</string>
|
||||
<string name="terms_of_use">Condizioni di utilizzo</string>
|
||||
<string name="acceptance_of_terms_is_required">L\'accettazione dei termini è obbligatoria</string>
|
||||
<string name="key_is_required">La chiave è obbligatoria</string>
|
||||
<string name="login">Accesso</string>
|
||||
<string name="generate_a_new_key">Genera una nuova chiave</string>
|
||||
<string name="loading_feed">Caricamento del feed</string>
|
||||
<string name="error_loading_replies">"Errore durante il caricamento delle risposte: "</string>
|
||||
<string name="try_again">Prova di nuovo</string>
|
||||
<string name="feed_is_empty">Il feed è vuoto.</string>
|
||||
<string name="refresh">Aggiorna</string>
|
||||
<string name="created">creato</string>
|
||||
<string name="with_description_of">con la descrizione di</string>
|
||||
<string name="and_picture">e l\'immagine</string>
|
||||
<string name="changed_chat_name_to">modificato il nome della chat in</string>
|
||||
<string name="description_to">descrizione per</string>
|
||||
<string name="and_picture_to">e immagine per</string>
|
||||
<string name="leave">Esci</string>
|
||||
<string name="unfollow">Non seguire più</string>
|
||||
<string name="channel_created">Canale creato</string>
|
||||
<string name="channel_information_changed_to">"Informazioni del canale modificate in"</string>
|
||||
<string name="public_chat">Chat Pubblica</string>
|
||||
<string name="posts_received">post ricevuti</string>
|
||||
<string name="remove">Cancella</string>
|
||||
<string name="translations_auto">Auto</string>
|
||||
<string name="translations_translated_from">tradotto da</string>
|
||||
<string name="translations_to">a</string>
|
||||
<string name="translations_show_in_lang_first">Mostra prima in %1$s</string>
|
||||
<string name="translations_always_translate_to_lang">Traduci sempre in %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Non tradurre mai da %1$s</string>
|
||||
<string name="nip_05">Indirizzo Nostr</string>
|
||||
<string name="never">mai</string>
|
||||
<string name="now">adesso</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
<string name="nudity">Nuditá</string>
|
||||
<string name="profanity_hateful_speech">Volgaritá / Linguaggio forte</string>
|
||||
<string name="report_hateful_speech">Segnala linguaggio forte</string>
|
||||
<string name="report_nudity_porn">Segnala Nuditá / Pornografia</string>
|
||||
<string name="others">altri</string>
|
||||
<string name="mark_all_known_as_read">Segna tutti i conosciuti come letti</string>
|
||||
<string name="mark_all_new_as_read">Segna tutti i nuovi come letti</string>
|
||||
<string name="mark_all_as_read">Segna tutti come letti</string>
|
||||
<string name="backup_keys">Esegui backup delle chiavi</string>
|
||||
<string name="account_backup_tips_md" tools:ignore="Typos"> ## Backup delle Chiavi e Consigli di Sicurezza
|
||||
\n\nIl tuo account è reso sicuro da una chiave privata. La chiave è una lunga stringa di caratteri casuali che inizia con **nsec1**. Chiunque abbia accesso alla tua chiave privata puó pubblicare contenuti con la tua identitá.
|
||||
\n\n- Non devi **mai** inserire la tua chiave privata in un sito o software di cui non ti fidi.
|
||||
\n- Gli sviluppatori di Amethyst non chiederanno **mai** la tua chiave privata.
|
||||
\n- Mantieni **sempre** al sicuro un backup della tua chiave privata per recuperare l\'account. Ti suggeriamo di usare un password manager.
|
||||
</string>
|
||||
<string name="secret_key_copied_to_clipboard">Chiave segreta (nsec) copiata negli appunti</string>
|
||||
<string name="copy_my_secret_key">Copia la mia chiave segreta</string>
|
||||
<string name="biometric_authentication_failed">Autenticazione fallita</string>
|
||||
<string name="biometric_error">Errore</string>
|
||||
<string name="badge_created_by">"Creato da %1$s"</string>
|
||||
<string name="badge_award_image_for">"Immagine premio badge per %1$s"</string>
|
||||
<string name="new_badge_award_notif">Hai ricevuto un nuovo premio Badge</string>
|
||||
<string name="award_granted_to">Premio badge assegnato a</string>
|
||||
<string name="copied_note_text_to_clipboard">Testo della nota copiato negli appunti</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">\@npub dell\'autore copiata negli appunti</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">ID della nota copiato (@note1) negli appunti</string>
|
||||
<string name="select_text_dialog_top">Seleziona testo</string>
|
||||
<string name="private_conversation_notification">"<Incapace di decriptare il messaggio privato>\n\nSei stato citato in una conversazione privata/criptata tra %1$s e %2$s."</string>
|
||||
<string name="account_switch_add_account_dialog_title">Aggiungi un nuovo profilo</string>
|
||||
<string name="drawer_accounts">Profili</string>
|
||||
<string name="account_switch_select_account">Seleziona profilo</string>
|
||||
<string name="account_switch_add_account_btn">Aggiungi un nuovo profilo</string>
|
||||
<string name="account_switch_active_account">Profilo attivo</string>
|
||||
<string name="account_switch_has_private_key">Ha la chiave privata</string>
|
||||
<string name="account_switch_pubkey_only">Solo lettura, nessuna chiave privata</string>
|
||||
<string name="back">Indietro</string>
|
||||
<string name="quick_action_select">Seleziona</string>
|
||||
<string name="quick_action_share_browser_link">Condividi Collegamento Browser</string>
|
||||
<string name="quick_action_share">Condividi</string>
|
||||
<string name="quick_action_copy_user_id">ID dell\'autore</string>
|
||||
<string name="quick_action_copy_note_id">ID della nota</string>
|
||||
<string name="quick_action_copy_text">Copia testo</string>
|
||||
<string name="quick_action_delete">Elimina</string>
|
||||
<string name="quick_action_unfollow">Smetti di seguire</string>
|
||||
<string name="quick_action_follow">Segui</string>
|
||||
<string name="quick_action_request_deletion_alert_title">Richiedi eliminazione</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Amethyst richiederà che la tua nota sia cancellata dai relè a cui sei attualmente connesso. Non vi è alcuna garanzia che la tua nota venga cancellata definitivamente da quei relè o da altri relè dove può essere memorizzata.</string>
|
||||
<string name="quick_action_block_dialog_btn">Blocca</string>
|
||||
<string name="quick_action_delete_dialog_btn">Elimina</string>
|
||||
<string name="quick_action_block">Blocca</string>
|
||||
<string name="quick_action_report">Segnala</string>
|
||||
<string name="quick_action_delete_button">Elimina</string>
|
||||
<string name="quick_action_dont_show_again_button">Non mostrare più</string>
|
||||
<string name="report_dialog_spam">Spam o truffe</string>
|
||||
<string name="report_dialog_profanity">Blasfemia o comportamento odioso</string>
|
||||
<string name="report_dialog_impersonation">Impersonificazione malevola</string>
|
||||
<string name="report_dialog_nudity">Nudità o contenuto grafico</string>
|
||||
<string name="report_dialog_illegal">Comportamento scorretto</string>
|
||||
<string name="report_dialog_blocking_a_user">Il blocco di un utente nasconderà il suo contenuto nella tua app. Le tue note sono ancora visibili pubblicamente, anche per le persone che blocchi. Gli utenti bloccati sono elencati nella schermata Filtri di sicurezza.</string>
|
||||
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blocca e Nascondi Utente]]></string>
|
||||
<string name="report_dialog_report_btn">Segnala un abuso</string>
|
||||
<string name="report_dialog_reminder_public">Tutte le segnalazioni pubblicate saranno visibili pubblicamente.</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">Facoltativamente fornisci un contesto aggiuntivo sulla tua segnalazione…</string>
|
||||
<string name="report_dialog_additional_reason_label">Contesto Aggiuntivo</string>
|
||||
<string name="report_dialog_select_reason_label">Motivo</string>
|
||||
<string name="report_dialog_select_reason_placeholder">Seleziona un motivo…</string>
|
||||
<string name="report_dialog_post_report_btn">Pubblica una segnalazione</string>
|
||||
<string name="report_dialog_title">Blocca e Segnala</string>
|
||||
<string name="block_only">Blocca</string>
|
||||
<string name="bookmarks">Segnalibri</string>
|
||||
<string name="private_bookmarks">Segnalibri privati</string>
|
||||
<string name="public_bookmarks">Segnalibri pubblici</string>
|
||||
<string name="add_to_private_bookmarks">Aggiungi ai segnalibri privati</string>
|
||||
<string name="add_to_public_bookmarks">Aggiungi ai segnalibri pubblici</string>
|
||||
<string name="remove_from_private_bookmarks">Rimuovi dai segnalibri privati</string>
|
||||
<string name="remove_from_public_bookmarks">Rimuovi dai segnalibri pubblici</string>
|
||||
<string name="wallet_connect_service">Servizio di Connessione Portafoglio</string>
|
||||
<string name="wallet_connect_service_explainer">Autorizza un Nostr Secret a pagare gli zaps senza lasciare l\'app. Mantieni il segreto al sicuro e usa un relè privato se possibile</string>
|
||||
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
|
||||
<string name="wallet_connect_service_relay">Wallet Connect Relè</string>
|
||||
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
|
||||
<string name="wallet_connect_service_show_secret">Mostra chiave segreta</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / chiave privata esadecimale</string>
|
||||
<string name="pledge_amount_in_sats">Importo in pegno in sats</string>
|
||||
<string name="post_poll">Pubblica sondaggio</string>
|
||||
<string name="poll_heading_required">Campi obbligatori:</string>
|
||||
<string name="poll_zap_recipients">Destinatari zap</string>
|
||||
<string name="poll_primary_description">Descrizione principale del sondaggio…</string>
|
||||
<string name="poll_option_index">Opzione %s</string>
|
||||
<string name="poll_option_description">Descrizione opzione sondaggio</string>
|
||||
<string name="poll_heading_optional">Campi facoltativi:</string>
|
||||
<string name="poll_zap_value_min">Zap minimo</string>
|
||||
<string name="poll_zap_value_max">Zap massimo</string>
|
||||
<string name="poll_consensus_threshold">Consenso</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time">Chiudi dopo</string>
|
||||
<string name="poll_closing_time_days">giorni</string>
|
||||
<string name="poll_is_closed">Il sondaggio è chiuso a nuovi voti</string>
|
||||
<string name="poll_zap_amount">Quantità in zap</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">Per questo tipo di sondaggio è consentito solo un voto per utente</string>
|
||||
<string name="looking_for_event">"Cercando l'evento %1$s"</string>
|
||||
<string name="custom_zaps_add_a_message">Aggiungi un messaggio pubblico</string>
|
||||
<string name="custom_zaps_add_a_message_private">Aggiungi un messaggio privato</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">Aggiungi un messaggio di fattura</string>
|
||||
<string name="custom_zaps_add_a_message_example">Grazie per tutto il tuo lavoro!</string>
|
||||
<string name="lightning_create_and_add_invoice">Crea e Aggiungi</string>
|
||||
<string name="poll_author_no_vote">Gli autori del sondaggio non possono votare nei propri sondaggi.</string>
|
||||
<string name="hash_verification_passed">Questo contenuto è lo stesso da quando è stato pubblicato</string>
|
||||
<string name="hash_verification_failed">Questo contenuto è cambiato. L\'autore potrebbe non aver visto o approvato la modifica</string>
|
||||
<string name="content_description_add_image">Aggiungi Immagine</string>
|
||||
<string name="content_description_add_video">Aggiungi Video</string>
|
||||
<string name="content_description_add_document">Aggiungi Documento</string>
|
||||
<string name="add_content">Aggiungi al Messaggio</string>
|
||||
<string name="content_description">Descrizione dei contenuti</string>
|
||||
<string name="content_description_example">Una barca blu in una spiaggia di sabbia bianca al tramonto</string>
|
||||
<string name="zap_type">Tipo di Zap</string>
|
||||
<string name="zap_type_explainer">Tipo di Zap per tutte le opzioni</string>
|
||||
<string name="zap_type_public">Pubblico</string>
|
||||
<string name="zap_type_public_explainer">Tutti possono vedere la transazione e il messaggio</string>
|
||||
<string name="zap_type_private">Privato</string>
|
||||
<string name="zap_type_private_explainer">Mittente e Destinatario possono vedersi e leggere il messaggio</string>
|
||||
<string name="zap_type_anonymous">Anonimo</string>
|
||||
<string name="zap_type_anonymous_explainer">Il destinatario e il pubblico non sanno chi ha inviato il pagamento</string>
|
||||
<string name="zap_type_nonzap">Non-Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Nessuna traccia in Nostr, solo in Lightning</string>
|
||||
<string name="file_server">File Server</string>
|
||||
<string name="zap_forward_lnAddress">Indirizzo LN o @User</string>
|
||||
<string name="upload_server_imgur">imgur.com - fidato</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur può modificare il file</string>
|
||||
<string name="upload_server_nostrimg">nostrimg.com - fidato</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg può modificare il file</string>
|
||||
<string name="upload_server_nostrbuild">nostr.build - fidato</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build può modificare il file</string>
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev - fidato</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev può modificare il file</string>
|
||||
<string name="upload_server_nostrcheckme">nostrcheck.me - fidato</string>
|
||||
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me può modificare il file</string>
|
||||
<string name="upload_server_imgur_nip94">Imgur verificabile (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Controlla se Imgur ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
|
||||
<string name="upload_server_nostrimg_nip94">NostrImg verificabile (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Controlla se NostrImg ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
|
||||
<string name="upload_server_nostrbuild_nip94">Nostr.build verificabile (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Controlla se Nostr.build ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94">Nostrfiles.dev verificabile (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Controlla se Nostrfiles.dev ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
|
||||
<string name="upload_server_nostrcheckme_nip94">Nostrcheck.me verificabile (NIP-94)</string>
|
||||
<string name="upload_server_nostrcheckme_nip94_explainer">Controlla se Nostrcheck.me ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
|
||||
<string name="upload_server_relays_nip95">I tuoi relè (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">I file sono ospitati dai tuoi relè. Nuovo NIP: controlla se lo supportano</string>
|
||||
<string name="connect_via_tor_short">Configurazione Tor/Orbot</string>
|
||||
<string name="connect_via_tor">Connettiti tramite la tua configurazione di Orbot</string>
|
||||
<string name="do_you_really_want_to_disable_tor_title">Disconnettere dal tuo Orbot/Tor?</string>
|
||||
<string name="do_you_really_want_to_disable_tor_text">I tuoi dati verranno immediatamente trasferiti nella rete regolare</string>
|
||||
<string name="yes">Sì</string>
|
||||
<string name="no">No</string>
|
||||
<string name="follow_list_selection">Segui Lista</string>
|
||||
<string name="follow_list_kind3follows">Tutti i seguiti</string>
|
||||
<string name="follow_list_global">Globale</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown"> ## Connettiti tramite Tor con Orbot
|
||||
\n\n1. Installa [Orbot](https://play.google. om/store/apps/details?id=org.torproject.android)
|
||||
\n2. Avvia Orbot
|
||||
\n3. In Orbot, controlla la porta Socks. Il valore predefinito utilizza 9050
|
||||
\n4. Se necessario, cambiare la porta in Orbot
|
||||
\n5. Configura la porta Socks in questa schermata
|
||||
\n6. Premi il pulsante Attiva per usare Orbot come proxy
|
||||
</string>
|
||||
<string name="orbot_socks_port">Porta Socks di Orbot</string>
|
||||
<string name="invalid_port_number">Numero della porta non valido</string>
|
||||
<string name="use_orbot">Usa Orbot</string>
|
||||
<string name="disconnect_from_your_orbot_setup">Disconnetti Tor/Orbot</string>
|
||||
<string name="app_notification_dms_channel_name">Messaggi privati</string>
|
||||
<string name="app_notification_dms_channel_description">Notifica quando arriva un messaggio privato</string>
|
||||
<string name="app_notification_zaps_channel_name">Zaps Ricevuti</string>
|
||||
<string name="app_notification_zaps_channel_description">Notifica quando qualcuno ti invia uno zap</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Da %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">per %1$s</string>
|
||||
<string name="reply_notify">Notificare: </string>
|
||||
<string name="channel_list_join_conversation">Unisciti Alla Conversazione</string>
|
||||
<string name="channel_list_user_or_group_id">ID utente o di gruppo</string>
|
||||
<string name="channel_list_user_or_group_id_demo">npub, nevent o esadecimale</string>
|
||||
<string name="channel_list_create_channel">Crea</string>
|
||||
<string name="channel_list_join_channel">Unisciti</string>
|
||||
<string name="today">Oggi</string>
|
||||
<string name="content_warning">Avviso sui contenuti</string>
|
||||
<string name="content_warning_explanation">Questo post contiene contenuti sensibili che alcune persone possono trovare offensivi o disturbanti</string>
|
||||
<string name="content_warning_hide_all_sensitive_content">Nascondi sempre i contenuti sensibili</string>
|
||||
<string name="content_warning_show_all_sensitive_content">Mostra sempre i contenuti sensibili</string>
|
||||
<string name="content_warning_see_warnings">Mostra sempre gli avvisi sui contenuti</string>
|
||||
<string name="recommended_apps">Consigliati: </string>
|
||||
<string name="filter_spam_from_strangers">Filtra spam dagli sconosciuti</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows">Avvisa quando i post hanno segnalazioni da parte di chi segui</string>
|
||||
<string name="new_reaction_symbol">Nuovo Simbolo Di Reazione</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">Nessun tipo di reazione selezionato. Premi a lungo per cambiare</string>
|
||||
<string name="zapraiser">Zapraiser</string>
|
||||
<string name="zapraiser_explainer">Aggiunge una quantità di sats da raccogliere per questo post. I client che supportano questa funzione la mostreranno con una barra di avanzamento per incentivare le donazioni</string>
|
||||
<string name="zapraiser_target_amount_in_sats">Ammontare dell\'obiettivo in sats</string>
|
||||
<string name="sats_to_complete">Zapraiser a %1$s. %2$s sats all\'obiettivo</string>
|
||||
<string name="read_from_relay">Leggi dal Relè</string>
|
||||
<string name="write_to_relay">Scrivi su Relè</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">Si è verificato un errore nel tentativo di ottenere le informazioni del relè da %1$s</string>
|
||||
<string name="owner">Proprietario</string>
|
||||
<string name="version">Versione</string>
|
||||
<string name="software">Software</string>
|
||||
<string name="contact">Contatto</string>
|
||||
<string name="supports">NIPs Supportati</string>
|
||||
<string name="admission_fees">Commissioni di Ammissione</string>
|
||||
<string name="payments_url">Url pagamenti</string>
|
||||
<string name="limitations">Limitazioni</string>
|
||||
<string name="countries">Paesi</string>
|
||||
<string name="languages">Lingue</string>
|
||||
<string name="tags">Tags</string>
|
||||
<string name="posting_policy">Politica di pubblicazione</string>
|
||||
<string name="message_length">Lunghezza del messaggio</string>
|
||||
<string name="subscriptions">Iscrizioni</string>
|
||||
<string name="filters">Filtri</string>
|
||||
<string name="subscription_id_length">Lunghezza ID Iscrizione</string>
|
||||
<string name="minimum_prefix">Prefisso minimo</string>
|
||||
<string name="maximum_event_tags">Tags massimi dell\'evento</string>
|
||||
<string name="content_length">Lunghezza del contenuto</string>
|
||||
<string name="minimum_pow">PoW Minima</string>
|
||||
<string name="auth">Auth</string>
|
||||
<string name="payment">Pagamento</string>
|
||||
<string name="cashu">Token di Cashu</string>
|
||||
<string name="cashu_redeem">Riscatta</string>
|
||||
<string name="no_lightning_address_set">Nessun indirizzo Lightning impostato</string>
|
||||
<string name="copied_token_to_clipboard">Token copiato negli appunti</string>
|
||||
<string name="live_stream_live_tag">IN DIRETTA</string>
|
||||
<string name="live_stream_offline_tag">NON IN LINEA</string>
|
||||
<string name="live_stream_ended_tag">CONCLUSO</string>
|
||||
<string name="live_stream_planned_tag">PROGRAMMATO</string>
|
||||
<string name="live_stream_is_offline">Livestream non è in linea</string>
|
||||
<string name="live_stream_has_ended">Livestream concluso</string>
|
||||
<string name="are_you_sure_you_want_to_log_out">La disconnessione elimina tutte le tue informazioni locali. Assicurati di avere le tue chiavi private salvate per evitare di perdere il tuo account. Vuoi continuare?</string>
|
||||
<string name="followed_tags">Tag Seguiti</string>
|
||||
<string name="relay_setup">Relè</string>
|
||||
<string name="discover_live">In diretta</string>
|
||||
<string name="discover_community">Comunità</string>
|
||||
<string name="discover_chat">Chat</string>
|
||||
<string name="community_approved_posts">Post approvati</string>
|
||||
<string name="groups_no_descriptor">Questo gruppo non ha una descrizione o regole. Parla con il proprietario per aggiungerne una</string>
|
||||
<string name="community_no_descriptor">Questa comunità non ha una descrizione. Parla con il proprietario per aggiungerne una</string>
|
||||
<string name="add_sensitive_content_label">Contenuto Sensibile</string>
|
||||
<string name="add_sensitive_content_description">Aggiunge un avviso di contenuto sensibile prima di mostrare questo contenuto</string>
|
||||
<string name="settings">Impostazioni</string>
|
||||
<string name="connectivity_type_always">Sempre</string>
|
||||
<string name="connectivity_type_wifi_only">Solo Wi-Fi</string>
|
||||
<string name="connectivity_type_never">Mai</string>
|
||||
<string name="system">Sistema</string>
|
||||
<string name="light">Chiaro</string>
|
||||
<string name="dark">Scuro</string>
|
||||
<string name="application_preferences">Preferenze dell\'Applicazione</string>
|
||||
<string name="language">Lingua</string>
|
||||
<string name="theme">Tema</string>
|
||||
<string name="automatically_load_images_gifs">Anteprima dell\'immagine</string>
|
||||
<string name="automatically_play_videos">Riproduzione video</string>
|
||||
<string name="automatically_show_url_preview">Anteprima URL</string>
|
||||
<string name="load_image">Carica immagine</string>
|
||||
<string name="spamming_users">Spammers</string>
|
||||
<string name="muted_button">Mutato. Clicca per riattivare l\'audio</string>
|
||||
<string name="mute_button">Audio acceso. Clicca per disattivarlo</string>
|
||||
<string name="search_button">Cerca registrazioni locali e remote</string>
|
||||
<string name="nip05_verified">L\'indirizzo Nostr è stato verificato</string>
|
||||
<string name="nip05_failed">Verifica dell\'indirizzo Nostr non riuscita</string>
|
||||
<string name="nip05_checking">Controllo dell\'indirizzo Nostr</string>
|
||||
<string name="select_deselect_all">Seleziona/Deseleziona tutto</string>
|
||||
<string name="default_relays">Predefinito</string>
|
||||
<string name="select_a_relay_to_continue">Seleziona un relè per continuare</string>
|
||||
<string name="zap_forward_title">Inoltra gli Zap a:</string>
|
||||
<string name="zap_forward_explainer">I client che lo supportano inoltreranno gli Zap all\'indirizzo LN o al profilo dell\'utente di seguito anzichè al tuo</string>
|
||||
<string name="geohash_title">Esporre la posizione come </string>
|
||||
<string name="geohash_explainer">Aggiunge un Geohash della tua posizione al post. Il pubblico saprà che ti trovi a 5 km dalla posizione attuale</string>
|
||||
<string name="add_sensitive_content_explainer">Aggiunge un avviso di contenuto sensibile prima di mostrare il tuo contenuto. Questo è ideale per qualsiasi contenuto NSFW o che alcune persone potrebbero trovare offensivo o disturbante</string>
|
||||
<string name="new_feature_nip24_might_not_be_available_title">Nuova Funzionalità</string>
|
||||
<string name="new_feature_nip24_might_not_be_available_description">Attivare questa modalità richiede ad Amethyst di inviare un messaggio NIP-24 (GiftWrapped, Sealed Direct e messaggi di gruppo). NIP-24 è nuovo e la maggior parte dei client non l\'hanno ancora implementato. Assicurarsi che il ricevitore stia utilizzando un client compatibile.</string>
|
||||
<string name="new_feature_nip24_activate">Attivare</string>
|
||||
<string name="messages_create_public_chat">Pubblico/a</string>
|
||||
<string name="messages_new_message">Privato</string>
|
||||
<string name="messages_new_message_to">A</string>
|
||||
<string name="messages_new_message_subject">Soggetto</string>
|
||||
<string name="messages_new_message_subject_caption">Argomento della conversazione</string>
|
||||
<string name="messages_new_message_to_caption">"\@User1, @User2, @User3"</string>
|
||||
<string name="messages_group_descriptor">Membri di questo gruppo</string>
|
||||
<string name="messages_new_subject_message">Spiegazione ai membri</string>
|
||||
<string name="messages_new_subject_message_placeholder">Cambiare il nome per i nuovi obiettivi.</string>
|
||||
<string name="language_description">Per l\'interfaccia dell\'app</string>
|
||||
<string name="theme_description">Tema scuro, chiaro o di sistema</string>
|
||||
<string name="automatically_load_images_gifs_description">Carica automaticamente immagini e GIF</string>
|
||||
<string name="automatically_play_videos_description">Riproduce automaticamente video e GIF</string>
|
||||
<string name="automatically_show_url_preview_description">Mostra anteprime URL</string>
|
||||
<string name="load_image_description">Quando caricare le immagini</string>
|
||||
<string name="copy_url_to_clipboard">Copia URL negli appunti</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copia ID della Nota negli appunti</string>
|
||||
<string name="created_at">Creato il</string>
|
||||
<string name="rules">Regole</string>
|
||||
<string name="status_update">Aggiorna il tuo stato</string>
|
||||
<string name="lightning_wallets_not_found">Errore analisi messaggio d\'errore</string>
|
||||
<string name="poll_zap_value_min_max_explainer">I voti sono ponderati con l\'importo dello zap. È possibile impostare una quantità minima per evitare gli spammer e una quantità massima per evitare che un grande zapper predomini il sondaggio. Usa lo stesso importo in entrambi i campi per assicurarti che ogni voto valga lo stesso importo. Lascialo vuoto per accettare qualsiasi importo.</string>
|
||||
<string name="error_dialog_zap_error">Impossibile inviare lo zap</string>
|
||||
<string name="error_dialog_talk_to_user">Invia un messaggio all\'utente</string>
|
||||
<string name="error_dialog_button_ok">OK</string>
|
||||
<string name="relay_information_document_error_assemble_url">Impossibile raggiungere %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_reach_server">Impossibile raggiungere %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_parse_result">Impossibile analizzare il risultato da %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_http_status">%1$s fallito con il codice %2$s</string>
|
||||
<string name="active_for">Attivo per: </string>
|
||||
<string name="active_for_home">Pagina iniziale</string>
|
||||
<string name="active_for_msg">Messaggi diretti</string>
|
||||
<string name="active_for_chats">Chat</string>
|
||||
<string name="active_for_global">Globale</string>
|
||||
<string name="active_for_search">Cerca</string>
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user