mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 15:44:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a14d4e6f4e | ||
|
|
de61c0350e | ||
|
|
3e3c97076d | ||
|
|
f62c986016 | ||
|
|
04108e893d | ||
|
|
042394796a | ||
|
|
bfce57f686 | ||
|
|
5bb983ba5a | ||
|
|
debae02421 | ||
|
|
6fe93fdf33 | ||
|
|
3d225423ef | ||
|
|
49e1da8812 | ||
|
|
04addc4cba | ||
|
|
c5cf2c5442 | ||
|
|
ddb7e6b7f2 | ||
|
|
e5af34efca | ||
|
|
b3bdbbed98 | ||
|
|
656b066d3a | ||
|
|
4248fadc69 | ||
|
|
18281cbc49 | ||
|
|
1d67af328a | ||
|
|
72b4f6acef | ||
|
|
26273270ba | ||
|
|
db3824c3c7 | ||
|
|
d6de1d03d8 | ||
|
|
8e4b7c66b5 | ||
|
|
45c750069a | ||
|
|
a411a72024 | ||
|
|
219d163680 | ||
|
|
a4c9f42205 | ||
|
|
a8936c54d8 | ||
|
|
74875bb564 | ||
|
|
61d412d941 | ||
|
|
5bb1e47edf | ||
|
|
b023614b5f | ||
|
|
7245718003 | ||
|
|
ba1f98d930 | ||
|
|
ceeabffcb7 | ||
|
|
2a943493b3 | ||
|
|
332a2f41b6 | ||
|
|
ce2cfb7c86 | ||
|
|
41ef1abf0e | ||
|
|
fce27c3c55 | ||
|
|
6662966ab3 | ||
|
|
f458f00edc | ||
|
|
8f1fbe10e9 | ||
|
|
69efa78fbc | ||
|
|
64a07d8760 | ||
|
|
cb672d92d0 | ||
|
|
95bcbfdded | ||
|
|
ac269727bc | ||
|
|
b1a7d18c0a | ||
|
|
619b59357b | ||
|
|
86dcc3ccd9 | ||
|
|
6772360ae7 | ||
|
|
05cbbd79cb | ||
|
|
a80c03a2d2 | ||
|
|
6c3abe17ed | ||
|
|
7cd09261de | ||
|
|
6ef1bee8a0 | ||
|
|
66b8b04db3 | ||
|
|
3b06abb2bc | ||
|
|
138fafa204 | ||
|
|
9cbdbb8592 | ||
|
|
1c0ec93cbb | ||
|
|
c6fa2b5f80 | ||
|
|
dd55ad18e3 | ||
|
|
284da365a1 | ||
|
|
7a06c2e9c4 | ||
|
|
61449774a1 | ||
|
|
81a3baa41c | ||
|
|
4b7e58dd1c | ||
|
|
14ce42401b | ||
|
|
ecb8344287 | ||
|
|
9bc5549d76 | ||
|
|
8ac8894ccd | ||
|
|
efb9814f1b | ||
|
|
3582f587b3 | ||
|
|
4fbc9f6d53 | ||
|
|
e9ec1c45a0 | ||
|
|
b2b7cba352 | ||
|
|
393fbd0359 | ||
|
|
47ae42e3fe | ||
|
|
5db3ede09e | ||
|
|
eca420d789 | ||
|
|
5347373cde | ||
|
|
05596cb9bf | ||
|
|
3d1310295b | ||
|
|
35c780f81b | ||
|
|
b021920eaa | ||
|
|
2b27ac3aec | ||
|
|
c692c9125b | ||
|
|
7322fd7aae | ||
|
|
07a92cd70d | ||
|
|
21a797be9d | ||
|
|
fe49cbabce | ||
|
|
9f674a0088 | ||
|
|
b44e94cb16 | ||
|
|
a8da645be3 | ||
|
|
c6e6bf6032 |
+9
@@ -3,30 +3,39 @@
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@@ -78,7 +78,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [x] Emoji Packs (Kind:30030)
|
||||
- [x] Personal Emoji Lists (Kind:10030)
|
||||
- [x] Classifieds (Kind:30403)
|
||||
- [x] Classifieds (Kind:30402)
|
||||
- [x] Private Messages and Small Groups (NIP-24)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44)
|
||||
|
||||
+70
-70
@@ -13,77 +13,77 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 333
|
||||
versionName "0.81.2"
|
||||
versionCode 337
|
||||
versionName "0.82.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resourceConfigurations += [
|
||||
'ar',
|
||||
'bn-rBD',
|
||||
'cs',
|
||||
'cy-rGB',
|
||||
'da-rDK',
|
||||
'de',
|
||||
'el-rGB',
|
||||
'en-rGB',
|
||||
'eo',
|
||||
'es',
|
||||
'es-rES',
|
||||
'es-rMX',
|
||||
'es-rUS',
|
||||
'et-rEE',
|
||||
'fa',
|
||||
'fi-rFI',
|
||||
'fo-rFO',
|
||||
'fr',
|
||||
'fr-rCA',
|
||||
'gu-rIN',
|
||||
'hi-rIN',
|
||||
'hr-rHR',
|
||||
'hu',
|
||||
'in',
|
||||
'in-rID',
|
||||
'it-rIT',
|
||||
'iw-rIL',
|
||||
'ja',
|
||||
'kk-rKZ',
|
||||
'ko-rKR',
|
||||
'ks-rIN',
|
||||
'ku-rTR',
|
||||
'lt-rLT',
|
||||
'ne-rNP',
|
||||
'night',
|
||||
'nl',
|
||||
'nl-rBE',
|
||||
'pcm-rNG',
|
||||
'pl-rPL',
|
||||
'pt-rBR',
|
||||
'pt-rPT',
|
||||
'ru',
|
||||
'ru-rUA',
|
||||
'sa-rIN',
|
||||
'sl-rSI',
|
||||
'so-rSO',
|
||||
'sr-rSP',
|
||||
'ss-rZA',
|
||||
'sv-rSE',
|
||||
'sw-rKE',
|
||||
'sw-rTZ',
|
||||
'ta',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'ur-rIN',
|
||||
'uz-rUZ',
|
||||
'vi-rVN',
|
||||
'zh',
|
||||
'zh-rCN',
|
||||
'zh-rHK',
|
||||
'zh-rSG',
|
||||
'zh-rTW'
|
||||
'ar',
|
||||
'bn-rBD',
|
||||
'cs',
|
||||
'cy-rGB',
|
||||
'da-rDK',
|
||||
'de',
|
||||
'el-rGB',
|
||||
'en-rGB',
|
||||
'eo',
|
||||
'es',
|
||||
'es-rES',
|
||||
'es-rMX',
|
||||
'es-rUS',
|
||||
'et-rEE',
|
||||
'fa',
|
||||
'fi-rFI',
|
||||
'fo-rFO',
|
||||
'fr',
|
||||
'fr-rCA',
|
||||
'gu-rIN',
|
||||
'hi-rIN',
|
||||
'hr-rHR',
|
||||
'hu',
|
||||
'in',
|
||||
'in-rID',
|
||||
'it-rIT',
|
||||
'iw-rIL',
|
||||
'ja',
|
||||
'kk-rKZ',
|
||||
'ko-rKR',
|
||||
'ks-rIN',
|
||||
'ku-rTR',
|
||||
'lt-rLT',
|
||||
'ne-rNP',
|
||||
'night',
|
||||
'nl',
|
||||
'nl-rBE',
|
||||
'pcm-rNG',
|
||||
'pl-rPL',
|
||||
'pt-rBR',
|
||||
'pt-rPT',
|
||||
'ru',
|
||||
'ru-rUA',
|
||||
'sa-rIN',
|
||||
'sl-rSI',
|
||||
'so-rSO',
|
||||
'sr-rSP',
|
||||
'ss-rZA',
|
||||
'sv-rSE',
|
||||
'sw-rKE',
|
||||
'sw-rTZ',
|
||||
'ta',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'ur-rIN',
|
||||
'uz-rUZ',
|
||||
'vi-rVN',
|
||||
'zh',
|
||||
'zh-rCN',
|
||||
'zh-rHK',
|
||||
'zh-rSG',
|
||||
'zh-rTW'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
// Zoomable images
|
||||
implementation 'net.engawapg.lib:zoomable:1.5.1'
|
||||
implementation 'net.engawapg.lib:zoomable:1.5.2'
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
@@ -196,7 +196,7 @@ dependencies {
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// HTML Parsing for Link Preview
|
||||
implementation 'org.jsoup:jsoup:1.16.2'
|
||||
implementation 'org.jsoup:jsoup:1.17.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
@@ -266,14 +266,14 @@ dependencies {
|
||||
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
|
||||
|
||||
// Video compression lib
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
|
||||
// Image compression lib
|
||||
implementation 'id.zelory:compressor:3.0.1'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.8'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
|
||||
androidTestImplementation "androidx.test.ext:junit-ktx:1.2.0-alpha01"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
|
||||
|
||||
@@ -2,73 +2,116 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.FileServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.amethyst.ui.actions.Nip96Uploader
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ImageUploadTesting {
|
||||
|
||||
val contentType = "image/gif"
|
||||
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
|
||||
|
||||
private fun testBase(server: FileServer) {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val contentTypePng = "image/png"
|
||||
val imagePng = "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
|
||||
|
||||
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
|
||||
val serverInfo = Nip96Retriever().loadInfo(
|
||||
server.baseUrl
|
||||
)
|
||||
|
||||
val bytes = Base64.getDecoder().decode(imagePng)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var url: String? = null
|
||||
var error: String? = null
|
||||
val account = Account(KeyPair())
|
||||
|
||||
ImageUploader(Account(KeyPair())).uploadImage(
|
||||
val result = Nip96Uploader(account).uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
server,
|
||||
onSuccess = { newUrl, contentType ->
|
||||
println("Uploaded $contentType to $url")
|
||||
url = newUrl
|
||||
countDownLatch.countDown()
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
error = it.message
|
||||
countDownLatch.countDown()
|
||||
contentTypePng,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverInfo,
|
||||
onProgress = {
|
||||
}
|
||||
)
|
||||
|
||||
countDownLatch.await()
|
||||
val url = result.tags!!.first() { it[0] == "url" }.get(1)
|
||||
val size = result.tags!!.firstOrNull() { it[0] == "size" }?.get(1)?.ifBlank { null }
|
||||
val dim = result.tags!!.firstOrNull() { it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
val hash = result.tags!!.firstOrNull() { it[0] == "x" }?.get(1)?.ifBlank { null }
|
||||
val contentType = result.tags!!.first() { it[0] == "m" }.get(1)
|
||||
val ox = result.tags!!.first() { it[0] == "ox" }.get(1)
|
||||
|
||||
Assert.assertNull(error)
|
||||
Assert.assertTrue(url?.startsWith("http") == true)
|
||||
Assert.assertTrue(url.startsWith("http"))
|
||||
|
||||
val imageData: ByteArray = ImageDownloader().waitAndGetImage(url) ?: run {
|
||||
fail("Should not be null")
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
contentTypePng,
|
||||
null,
|
||||
onReady = {
|
||||
if (dim != null) {
|
||||
assertEquals(dim, it.dim)
|
||||
}
|
||||
if (size != null) {
|
||||
assertEquals(size, it.size.toString())
|
||||
}
|
||||
if (hash != null) {
|
||||
assertEquals(hash, it.hash)
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
fail("It should not fail")
|
||||
}
|
||||
)
|
||||
|
||||
// delay(1000)
|
||||
|
||||
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
testBase(ImgurServer())
|
||||
fun testNostrCheck() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrBuildUpload() = runBlocking {
|
||||
testBase(NostrBuildServer())
|
||||
fun testNostrage() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrImgUpload() = runBlocking {
|
||||
testBase(NostrImgServer())
|
||||
fun testSove() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrFilesDevUpload() = runBlocking {
|
||||
testBase(NostrFilesDevServer())
|
||||
fun testNostrBuild() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testSovbit() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testVoidCat() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4127,6 +4127,31 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUrlsEndingInPeriod() {
|
||||
val text = "That’s it! http://vitorpamplona.com/. That’s the note"
|
||||
|
||||
val state = RichTextParser().parseText(text, EmptyTagList)
|
||||
|
||||
printStateForDebug(state)
|
||||
|
||||
val expectedResult = listOf<String>(
|
||||
"RegularText(That’s)",
|
||||
"RegularText(it!)",
|
||||
"Link(http://vitorpamplona.com/.)",
|
||||
"RegularText(That’s)",
|
||||
"RegularText(the)",
|
||||
"RegularText(note)"
|
||||
)
|
||||
|
||||
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
|
||||
Assert.assertEquals(
|
||||
expectedResult[index],
|
||||
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun printStateForDebug(state: RichTextViewerState) {
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
paragraph.words.forEach { seg ->
|
||||
|
||||
@@ -15,13 +15,13 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.model.parseBooleanType
|
||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.parseThemeType
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
@@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
@@ -57,9 +59,6 @@ private object PrefKeys {
|
||||
const val SAVED_ACCOUNTS = "all_saved_accounts"
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val FOLLOWING_COMMUNITIES = "following_communities"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
@@ -102,6 +101,7 @@ object LocalPreferences {
|
||||
|
||||
private var _currentAccount: String? = null
|
||||
private var _savedAccounts: List<AccountInfo>? = null
|
||||
private var _cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
|
||||
|
||||
suspend fun currentAccount(): String? {
|
||||
if (_currentAccount == null) {
|
||||
@@ -264,7 +264,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
|
||||
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name)
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, Event.mapper.writeValueAsString(account.defaultFileServer))
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList.value)
|
||||
@@ -289,9 +289,9 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? {
|
||||
val acc = loadCurrentAccountFromEncryptedStorage(currentAccount())
|
||||
acc?.registerObservers()
|
||||
return acc
|
||||
return currentAccount()?.let {
|
||||
loadCurrentAccountFromEncryptedStorage(it)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun migrateOldSharedSettings(): Settings? {
|
||||
@@ -379,7 +379,23 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
|
||||
val mutex = Mutex()
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
if (_cachedAccounts.containsKey(npub)) {
|
||||
return@withContext _cachedAccounts.get(npub)
|
||||
}
|
||||
|
||||
val account = innerLoadCurrentAccountFromEncryptedStorage(npub)
|
||||
account?.registerObservers()
|
||||
|
||||
_cachedAccounts.put(npub, account)
|
||||
|
||||
return@withContext account
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
@@ -387,9 +403,6 @@ object LocalPreferences {
|
||||
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
|
||||
val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
|
||||
println("LocalRelays: $it")
|
||||
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
|
||||
@@ -414,9 +427,15 @@ object LocalPreferences {
|
||||
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
|
||||
} ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
|
||||
ServersAvailable.values().firstOrNull() { it.name == serverName }
|
||||
} ?: ServersAvailable.NOSTR_BUILD
|
||||
val defaultFileServer = try {
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
|
||||
Event.mapper.readValue<Nip96MediaServers.ServerName>(serverName)
|
||||
} ?: Nip96MediaServers.DEFAULT[0]
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
|
||||
e.printStackTrace()
|
||||
Nip96MediaServers.DEFAULT[0]
|
||||
}
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
@@ -485,7 +504,7 @@ object LocalPreferences {
|
||||
NostrSignerInternal(keyPair)
|
||||
}
|
||||
|
||||
return@with Account(
|
||||
val account = Account(
|
||||
keyPair = keyPair,
|
||||
signer = signer,
|
||||
localRelays = localRelays,
|
||||
@@ -512,6 +531,16 @@ object LocalPreferences {
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute
|
||||
)
|
||||
|
||||
// Loads from DB
|
||||
account.userProfile()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Loads Live Objects
|
||||
account.userProfile().live()
|
||||
}
|
||||
|
||||
return@with account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,15 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -39,6 +45,9 @@ class ServiceManager {
|
||||
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
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var collectorJob: Job? = null
|
||||
|
||||
private fun start(account: Account) {
|
||||
this.account = account
|
||||
start()
|
||||
@@ -72,10 +81,30 @@ class ServiceManager {
|
||||
}
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
|
||||
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
|
||||
Client.reconnect(relaySet)
|
||||
|
||||
collectorJob?.cancel()
|
||||
collectorJob = null
|
||||
collectorJob = scope.launch {
|
||||
myAccount.userProfile().flow().relays.stateFlow.collect {
|
||||
if (isStarted) {
|
||||
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
|
||||
Client.reconnect(newRelaySet, onlyIfChanged = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrAccountDataSource.otherAccounts = LocalPreferences.allSavedAccounts().mapNotNull {
|
||||
try {
|
||||
it.npub.bechToBytes().toHexKey()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
@@ -102,6 +131,9 @@ class ServiceManager {
|
||||
private fun pause() {
|
||||
Log.d("ServiceManager", "Pausing Relay Services")
|
||||
|
||||
collectorJob?.cancel()
|
||||
collectorJob = null
|
||||
|
||||
NostrAccountDataSource.stopSync()
|
||||
NostrHomeDataSource.stopSync()
|
||||
NostrChannelDataSource.stopSync()
|
||||
@@ -120,7 +152,7 @@ class ServiceManager {
|
||||
NostrUserProfileDataSource.stopSync()
|
||||
NostrVideoDataSource.stopSync()
|
||||
|
||||
Client.disconnect()
|
||||
Client.reconnect(null)
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
@@ -181,4 +213,9 @@ class ServiceManager {
|
||||
fun pauseForGood() {
|
||||
forceRestart(null, false, true)
|
||||
}
|
||||
|
||||
fun pauseForGoodAndClearAccount() {
|
||||
account = null
|
||||
forceRestart(null, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ import androidx.lifecycle.switchMap
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.Contact
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
@@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiUrl
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GeneralListEvent
|
||||
@@ -56,6 +58,7 @@ import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NIP24Factory
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
@@ -92,6 +95,7 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.math.BigDecimal
|
||||
import java.net.Proxy
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
@@ -128,7 +132,7 @@ class Account(
|
||||
var zapAmountChoices: List<Long> = DefaultZapAmounts,
|
||||
var reactionChoices: List<String> = DefaultReactions,
|
||||
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
|
||||
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
|
||||
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
|
||||
var defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
|
||||
var defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
@@ -210,6 +214,8 @@ class Account(
|
||||
}
|
||||
result?.let {
|
||||
emit(it)
|
||||
} ?: run {
|
||||
emit(LiveFollowLists())
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
@@ -241,6 +247,8 @@ class Account(
|
||||
}
|
||||
result?.let {
|
||||
emit(it)
|
||||
} ?: run {
|
||||
emit(LiveFollowLists())
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
@@ -272,6 +280,8 @@ class Account(
|
||||
}
|
||||
result?.let {
|
||||
emit(it)
|
||||
} ?: run {
|
||||
emit(LiveFollowLists())
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
@@ -303,6 +313,8 @@ class Account(
|
||||
}
|
||||
result?.let {
|
||||
emit(it)
|
||||
} ?: run {
|
||||
emit(LiveFollowLists())
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
@@ -338,18 +350,22 @@ class Account(
|
||||
) { localLive, blockList, muteList ->
|
||||
checkNotInMainThread()
|
||||
|
||||
val resultBlockList = withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
(blockList.note.event as? PeopleListEvent)?.publicAndPrivateUsersAndWords(signer) {
|
||||
continuation.resume(it)
|
||||
val resultBlockList = (blockList.note.event as? PeopleListEvent)?.let {
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
it.publicAndPrivateUsersAndWords(signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: PeopleListEvent.UsersAndWords()
|
||||
|
||||
val resultMuteList = withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
(muteList.note.event as? MuteListEvent)?.publicAndPrivateUsersAndWords(signer) {
|
||||
continuation.resume(it)
|
||||
val resultMuteList = (muteList.note.event as? MuteListEvent)?.let {
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
it.publicAndPrivateUsersAndWords(signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: PeopleListEvent.UsersAndWords()
|
||||
@@ -436,7 +452,7 @@ class Account(
|
||||
val contactList = userProfile().latestContactList
|
||||
|
||||
if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
val event = ContactListEvent.updateRelayList(
|
||||
ContactListEvent.updateRelayList(
|
||||
earlierVersion = contactList,
|
||||
relayUse = relays,
|
||||
signer = signer
|
||||
@@ -445,7 +461,7 @@ class Account(
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
val event = ContactListEvent.createFromScratch(
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = listOf(),
|
||||
followTags = listOf(),
|
||||
followGeohashes = listOf(),
|
||||
@@ -683,7 +699,9 @@ class Account(
|
||||
suspend fun delete(notes: List<Note>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex }
|
||||
val myNotes = notes.filter { it.author == userProfile() }.mapNotNull {
|
||||
it.event?.id()
|
||||
}
|
||||
|
||||
if (myNotes.isNotEmpty()) {
|
||||
DeletionEvent.create(myNotes, signer) {
|
||||
@@ -693,7 +711,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createHTTPAuthorization(url: String, method: String, body: String? = null, onReady: (HTTPAuthorizationEvent) -> Unit) {
|
||||
fun createHTTPAuthorization(url: String, method: String, body: ByteArray? = null, onReady: (HTTPAuthorizationEvent) -> Unit) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady)
|
||||
@@ -949,7 +967,13 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createNip95(byteArray: ByteArray, headerInfo: FileHeader, onReady: (Pair<FileStorageEvent, FileStorageHeaderEvent>) -> Unit) {
|
||||
fun createNip95(
|
||||
byteArray: ByteArray,
|
||||
headerInfo: FileHeader,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onReady: (Pair<FileStorageEvent, FileStorageHeaderEvent>) -> Unit
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
FileStorageEvent.create(
|
||||
@@ -964,8 +988,8 @@ class Account(
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
alt = headerInfo.alt,
|
||||
sensitiveContent = headerInfo.sensitiveContent,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
signer = signer
|
||||
) { signedEvent ->
|
||||
onReady(
|
||||
@@ -975,7 +999,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
|
||||
fun consumeAndSendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
Client.send(data, relayList = relayList)
|
||||
@@ -987,7 +1011,19 @@ class Account(
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
|
||||
private fun sendHeader(signedEvent: FileHeaderEvent, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
|
||||
fun consumeNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
|
||||
LocalCache.consume(data, null)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null) {
|
||||
Client.send(data, relayList = relayList)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
}
|
||||
|
||||
fun sendHeader(signedEvent: FileHeaderEvent, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
@@ -996,24 +1032,123 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendHeader(headerInfo: FileHeader, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
|
||||
fun createHeader(
|
||||
imageUrl: String,
|
||||
magnetUri: String?,
|
||||
headerInfo: FileHeader,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
originalHash: String? = null,
|
||||
onReady: (FileHeaderEvent) -> Unit
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
FileHeaderEvent.create(
|
||||
url = headerInfo.url,
|
||||
url = imageUrl,
|
||||
magnetUri = magnetUri,
|
||||
mimeType = headerInfo.mimeType,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
alt = headerInfo.alt,
|
||||
sensitiveContent = headerInfo.sensitiveContent,
|
||||
alt = alt,
|
||||
originalHash = originalHash,
|
||||
sensitiveContent = sensitiveContent,
|
||||
signer = signer
|
||||
) { event ->
|
||||
onReady(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendHeader(
|
||||
imageUrl: String,
|
||||
magnetUri: String?,
|
||||
headerInfo: FileHeader,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
originalHash: String? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
onReady: (Note) -> Unit
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
FileHeaderEvent.create(
|
||||
url = imageUrl,
|
||||
magnetUri = magnetUri,
|
||||
mimeType = headerInfo.mimeType,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
alt = alt,
|
||||
originalHash = originalHash,
|
||||
sensitiveContent = sensitiveContent,
|
||||
signer = signer
|
||||
) { event ->
|
||||
sendHeader(event, relayList = relayList, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendClassifieds(
|
||||
title: String,
|
||||
price: Price,
|
||||
condition: ClassifiedsEvent.CONDITION,
|
||||
location: String,
|
||||
category: String,
|
||||
message: String,
|
||||
replyTo: List<Note>?,
|
||||
mentions: List<User>?,
|
||||
directMentions: Set<HexKey>,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
val addresses = replyTo?.mapNotNull { it.address() }
|
||||
|
||||
ClassifiedsEvent.create(
|
||||
dTag = UUID.randomUUID().toString(),
|
||||
title = title,
|
||||
price = price,
|
||||
condition = condition,
|
||||
summary = message,
|
||||
image = null,
|
||||
location = location,
|
||||
category = category,
|
||||
message = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
addresses = addresses,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
|
||||
replyTo?.forEach {
|
||||
it.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPost(
|
||||
message: String,
|
||||
replyTo: List<Note>?,
|
||||
@@ -1026,7 +1161,8 @@ class Account(
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1047,6 +1183,7 @@ class Account(
|
||||
root = root,
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
@@ -1084,7 +1221,8 @@ class Account(
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1106,7 +1244,8 @@ class Account(
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
@@ -1125,7 +1264,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List<Event>? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = replyTo?.map { it.idHex }
|
||||
@@ -1140,6 +1279,7 @@ class Account(
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
@@ -1147,7 +1287,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
|
||||
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List<Event>? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
@@ -1163,6 +1303,7 @@ class Account(
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
@@ -1470,6 +1611,16 @@ class Account(
|
||||
return LocalCache.getOrCreateAddressableNote(aTag)
|
||||
}
|
||||
|
||||
fun getFileServersNote(): AddressableNote {
|
||||
val aTag = ATag(
|
||||
FileServersEvent.kind,
|
||||
userProfile().pubkeyHex,
|
||||
"",
|
||||
null
|
||||
)
|
||||
return LocalCache.getOrCreateAddressableNote(aTag)
|
||||
}
|
||||
|
||||
fun getBlockList(): PeopleListEvent? {
|
||||
return getBlockListNote().event as? PeopleListEvent
|
||||
}
|
||||
@@ -1478,6 +1629,10 @@ class Account(
|
||||
return getMuteListNote().event as? MuteListEvent
|
||||
}
|
||||
|
||||
fun getFileServersList(): FileServersEvent? {
|
||||
return getFileServersNote().event as? FileServersEvent
|
||||
}
|
||||
|
||||
fun hideWord(word: String) {
|
||||
val muteList = getMuteList()
|
||||
|
||||
@@ -1489,7 +1644,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
} else {
|
||||
MuteListEvent.createListWithWord(
|
||||
@@ -1498,7 +1653,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1514,7 +1669,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1528,7 +1683,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1544,7 +1699,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
} else {
|
||||
MuteListEvent.createListWithUser(
|
||||
@@ -1553,7 +1708,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1569,7 +1724,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1583,7 +1738,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1598,7 +1753,7 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultFileServer(server: ServersAvailable) {
|
||||
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
|
||||
defaultFileServer = server
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
@@ -1761,7 +1916,7 @@ class Account(
|
||||
?: Constants.defaultRelays.filter { defaultRelay -> defaultRelay.url == it.key }.firstOrNull()?.feedTypes
|
||||
?: FeedType.values().toSet()
|
||||
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes, proxy)
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
|
||||
} ?: return null
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
@@ -1774,8 +1929,7 @@ class Account(
|
||||
it.url,
|
||||
it.read,
|
||||
it.write,
|
||||
it.feedTypes,
|
||||
proxy
|
||||
it.feedTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1785,7 +1939,7 @@ class Account(
|
||||
|
||||
fun convertLocalRelays(): Array<Relay> {
|
||||
return localRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes, proxy)
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
@@ -1799,15 +1953,6 @@ class Account(
|
||||
return (activeRelays() ?: convertLocalRelays()).filter { it.write }
|
||||
}
|
||||
|
||||
fun reconnectIfRelaysHaveChanged() {
|
||||
val newRelaySet = activeRelays() ?: convertLocalRelays()
|
||||
if (!Client.isSameRelaySetConfig(newRelaySet)) {
|
||||
Client.disconnect()
|
||||
Client.connect(newRelaySet)
|
||||
RelayPool.requestAndWatch()
|
||||
}
|
||||
}
|
||||
|
||||
fun isAllHidden(users: Set<HexKey>): Boolean {
|
||||
return users.all { isHidden(it) }
|
||||
}
|
||||
@@ -1886,7 +2031,7 @@ class Account(
|
||||
).toSet()
|
||||
}
|
||||
|
||||
suspend fun saveRelayList(value: List<RelaySetupInfo>) {
|
||||
fun saveRelayList(value: List<RelaySetupInfo>) {
|
||||
try {
|
||||
localRelays = value.toSet()
|
||||
return sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) })
|
||||
@@ -1932,13 +2077,6 @@ class Account(
|
||||
}
|
||||
|
||||
suspend fun registerObservers() = withContext(Dispatchers.Main) {
|
||||
// Observes relays to restart connections
|
||||
userProfile().live().relays.observeForever {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
reconnectIfRelaysHaveChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// saves contact list for the next time.
|
||||
userProfile().live().follows.observeForever {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -20,11 +20,8 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
super.updateChannelInfo(creator, updatedAt)
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String {
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
@@ -276,7 +277,7 @@ object LocalCache {
|
||||
// avoids processing empty contact lists.
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
|
||||
user.updateContactList(event)
|
||||
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
|
||||
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,16 +427,17 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: MuteListEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: PeopleListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: AdvertisedRelayListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: EmojiPackSelectionEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: EmojiPackEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: ClassifiedsEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: PinListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: RelaySetEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: AudioTrackEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: MuteListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: FileServersEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: PeopleListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: AdvertisedRelayListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: EmojiPackSelectionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: EmojiPackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: ClassifiedsEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: PinListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: RelaySetEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: AudioTrackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: StatusEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
@@ -458,7 +460,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: BadgeDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
|
||||
fun consume(event: BadgeProfilesEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
@@ -504,14 +506,14 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun comsume(event: NNSEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: AppDefinitionEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarDateSlotEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarTimeSlotEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarRSVPEvent) { consumeBaseReplaceable(event) }
|
||||
private fun comsume(event: NNSEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: AppDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CalendarEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CalendarDateSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CalendarTimeSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CalendarRSVPEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
|
||||
private fun consumeBaseReplaceable(event: BaseAddressableEvent) {
|
||||
private fun consumeBaseReplaceable(event: BaseAddressableEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
@@ -521,6 +523,11 @@ object LocalCache {
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
@@ -531,7 +538,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: AppRecommendationEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: AppRecommendationEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: RecommendRelayEvent) {
|
||||
@@ -776,7 +783,7 @@ object LocalCache {
|
||||
|
||||
fun consume(event: ChannelMetadataEvent) {
|
||||
val channelId = event.channel()
|
||||
// Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
|
||||
// Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}")
|
||||
if (channelId.isNullOrBlank()) return
|
||||
|
||||
// new event
|
||||
@@ -784,10 +791,8 @@ object LocalCache {
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
if (event.createdAt > oldChannel.updatedMetadataAt) {
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
if (oldChannel is PublicChatChannel) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
}
|
||||
if (oldChannel is PublicChatChannel) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
}
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -1511,26 +1516,26 @@ object LocalCache {
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is AdvertisedRelayListEvent -> consume(event)
|
||||
is AppDefinitionEvent -> consume(event)
|
||||
is AppRecommendationEvent -> consume(event)
|
||||
is AdvertisedRelayListEvent -> consume(event, relay)
|
||||
is AppDefinitionEvent -> consume(event, relay)
|
||||
is AppRecommendationEvent -> consume(event, relay)
|
||||
is AudioHeaderEvent -> consume(event, relay)
|
||||
is AudioTrackEvent -> consume(event)
|
||||
is AudioTrackEvent -> consume(event, relay)
|
||||
is BadgeAwardEvent -> consume(event)
|
||||
is BadgeDefinitionEvent -> consume(event)
|
||||
is BadgeDefinitionEvent -> consume(event, relay)
|
||||
is BadgeProfilesEvent -> consume(event)
|
||||
is BookmarkListEvent -> consume(event)
|
||||
is CalendarEvent -> consume(event)
|
||||
is CalendarDateSlotEvent -> consume(event)
|
||||
is CalendarTimeSlotEvent -> consume(event)
|
||||
is CalendarRSVPEvent -> consume(event)
|
||||
is CalendarEvent -> consume(event, relay)
|
||||
is CalendarDateSlotEvent -> consume(event, relay)
|
||||
is CalendarTimeSlotEvent -> consume(event, relay)
|
||||
is CalendarRSVPEvent -> consume(event, relay)
|
||||
is ChannelCreateEvent -> consume(event)
|
||||
is ChannelHideMessageEvent -> consume(event)
|
||||
is ChannelMessageEvent -> consume(event, relay)
|
||||
is ChannelMetadataEvent -> consume(event)
|
||||
is ChannelMuteUserEvent -> consume(event)
|
||||
is ChatMessageEvent -> consume(event, relay)
|
||||
is ClassifiedsEvent -> consume(event)
|
||||
is ClassifiedsEvent -> consume(event, relay)
|
||||
is CommunityDefinitionEvent -> consume(event, relay)
|
||||
is CommunityPostApprovalEvent -> {
|
||||
event.containedPost()?.let {
|
||||
@@ -1540,11 +1545,12 @@ object LocalCache {
|
||||
}
|
||||
is ContactListEvent -> consume(event)
|
||||
is DeletionEvent -> consume(event)
|
||||
is EmojiPackEvent -> consume(event)
|
||||
is EmojiPackSelectionEvent -> consume(event)
|
||||
is EmojiPackEvent -> consume(event, relay)
|
||||
is EmojiPackSelectionEvent -> consume(event, relay)
|
||||
is SealedGossipEvent -> consume(event, relay)
|
||||
|
||||
is FileHeaderEvent -> consume(event, relay)
|
||||
is FileServersEvent -> consume(event, relay)
|
||||
is FileStorageEvent -> consume(event, relay)
|
||||
is FileStorageHeaderEvent -> consume(event, relay)
|
||||
is GiftWrapEvent -> consume(event, relay)
|
||||
@@ -1563,15 +1569,15 @@ object LocalCache {
|
||||
is LnZapPaymentResponseEvent -> consume(event)
|
||||
is LongTextNoteEvent -> consume(event, relay)
|
||||
is MetadataEvent -> consume(event)
|
||||
is MuteListEvent -> consume(event)
|
||||
is NNSEvent -> comsume(event)
|
||||
is MuteListEvent -> consume(event, relay)
|
||||
is NNSEvent -> comsume(event, relay)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event)
|
||||
is PeopleListEvent -> consume(event)
|
||||
is PinListEvent -> consume(event, relay)
|
||||
is PeopleListEvent -> consume(event, relay)
|
||||
is PollNoteEvent -> consume(event, relay)
|
||||
is ReactionEvent -> consume(event)
|
||||
is RecommendRelayEvent -> consume(event)
|
||||
is RelaySetEvent -> consume(event)
|
||||
is RelaySetEvent -> consume(event, relay)
|
||||
is ReportEvent -> consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
enum class ServersAvailable {
|
||||
// IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
NOSTRFILES_DEV,
|
||||
NOSTRCHECK_ME,
|
||||
|
||||
// IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NOSTRFILES_DEV_NIP_94,
|
||||
NOSTRCHECK_ME_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Stable
|
||||
@@ -123,6 +124,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
flowSet?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
@@ -356,6 +358,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
var liveSet: UserLiveSet? = null
|
||||
var flowSet: UserFlowSet? = null
|
||||
|
||||
fun live(): UserLiveSet {
|
||||
if (liveSet == null) {
|
||||
@@ -383,6 +386,47 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun createOrDestroyFlowSync(create: Boolean) {
|
||||
if (create) {
|
||||
if (flowSet == null) {
|
||||
flowSet = UserFlowSet(this)
|
||||
}
|
||||
} else {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
flowSet?.destroy()
|
||||
flowSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flow(): UserFlowSet {
|
||||
if (flowSet == null) {
|
||||
createOrDestroyFlowSync(true)
|
||||
}
|
||||
return flowSet!!
|
||||
}
|
||||
|
||||
fun clearFlow() {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
createOrDestroyFlowSync(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserFlowSet(u: User) {
|
||||
// Observers line up here.
|
||||
val relays = UserBundledRefresherFlow(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return relays.stateFlow.subscriptionCount.value > 0
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
relays.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -491,6 +535,27 @@ class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserSta
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserBundledRefresherFlow(val user: User) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
val stateFlow = MutableStateFlow(UserState(user))
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
stateFlow.emit(UserState(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.removeQueryParams
|
||||
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
@@ -21,6 +22,8 @@ import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
@@ -85,11 +88,25 @@ class RichTextParser() {
|
||||
}
|
||||
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
val removedParamsFromUrl = removeQueryParams(fullUrl)
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlImage(fullUrl)
|
||||
val frags = URI(fullUrl).fragments()
|
||||
ZoomableUrlImage(
|
||||
url = fullUrl,
|
||||
description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") },
|
||||
hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") },
|
||||
blurhash = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") },
|
||||
dim = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") }
|
||||
)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlVideo(fullUrl)
|
||||
val frags = URI(fullUrl).fragments()
|
||||
ZoomableUrlVideo(
|
||||
url = fullUrl,
|
||||
description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") },
|
||||
hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") },
|
||||
blurhash = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") },
|
||||
dim = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") }
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -110,6 +127,16 @@ class RichTextParser() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun URI.fragments(): Map<String, String> {
|
||||
if (rawFragment == null) return emptyMap()
|
||||
return rawFragment.split('&').associate {
|
||||
val parts = it.split('=')
|
||||
val name = parts.firstOrNull() ?: ""
|
||||
val value = parts.getOrNull(1) ?: ""
|
||||
Pair(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
|
||||
var paragraphSegments = persistentListOf<ParagraphState>()
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ data class CashuToken(
|
||||
val token: String,
|
||||
val mint: String,
|
||||
val totalAmount: Long,
|
||||
val fees: Int,
|
||||
val redeemInvoiceAmount: Long,
|
||||
val proofs: JsonNode
|
||||
)
|
||||
|
||||
@@ -38,10 +36,8 @@ class CashuProcessor {
|
||||
for (proof in proofs) {
|
||||
totalAmount += proof.get("amount").asLong()
|
||||
}
|
||||
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
|
||||
val redeemInvoiceAmount = totalAmount - fees
|
||||
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
|
||||
} catch (e: Exception) {
|
||||
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
|
||||
}
|
||||
@@ -53,10 +49,29 @@ class CashuProcessor {
|
||||
runCatching {
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
|
||||
message = "Redeem Cashu",
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, onSuccess, onError, context)
|
||||
milliSats = token.totalAmount * 1000, // Make invoice and leave room for fees
|
||||
message = "Calculate Fees for Cashu",
|
||||
onSuccess = { baseInvoice ->
|
||||
feeCalculator(
|
||||
token.mint,
|
||||
baseInvoice,
|
||||
onSuccess = { fees ->
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = (token.totalAmount - fees) * 1000, // Make invoice and leave room for fees
|
||||
message = "Redeem Cashu",
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, fees, onSuccess, onError, context)
|
||||
},
|
||||
onProgress = {
|
||||
},
|
||||
onError = onError,
|
||||
context = context
|
||||
)
|
||||
},
|
||||
onError = onError,
|
||||
context
|
||||
)
|
||||
},
|
||||
onProgress = {
|
||||
},
|
||||
@@ -66,7 +81,59 @@ class CashuProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
fun feeCalculator(
|
||||
mintAddress: String,
|
||||
invoice: String,
|
||||
onSuccess: (Int) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
val jsonObject = factory.objectNode()
|
||||
jsonObject.put("pr", invoice)
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
val body = it.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
|
||||
val feeCost = tree?.get("fee")?.asInt()
|
||||
|
||||
if (feeCost != null) {
|
||||
onSuccess(
|
||||
feeCost
|
||||
)
|
||||
} else {
|
||||
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
|
||||
onError(
|
||||
context.getString(R.string.cashu_failed_redemption),
|
||||
if (msg != null) {
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
|
||||
} else {
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, fees: Int, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
@@ -92,8 +159,8 @@ class CashuProcessor {
|
||||
|
||||
if (successful) {
|
||||
onSuccess(
|
||||
context.getString(R.string.cashu_sucessful_redemption),
|
||||
context.getString(R.string.cashu_sucessful_redemption_explainer, token.totalAmount.toString(), token.fees.toString())
|
||||
context.getString(R.string.cashu_successful_redemption),
|
||||
context.getString(R.string.cashu_successful_redemption_explainer, token.totalAmount.toString(), fees.toString())
|
||||
)
|
||||
} else {
|
||||
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
|
||||
@@ -108,7 +175,7 @@ class CashuProcessor {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError(context.getString(R.string.cashu_sucessful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
|
||||
onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,30 +2,39 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.media.MediaDataSource
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMetadataRetriever.BitmapParams
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
val url: String,
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val alt: String? = null,
|
||||
val sensitiveContent: Boolean = false
|
||||
val blurHash: String?
|
||||
) {
|
||||
companion object {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
suspend fun prepare(
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
|
||||
if (imageData != null) {
|
||||
prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError)
|
||||
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
|
||||
} else {
|
||||
onError()
|
||||
}
|
||||
@@ -37,10 +46,8 @@ class FileHeader(
|
||||
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
dimPrecomputed: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
@@ -75,11 +82,45 @@ class FileHeader(
|
||||
} else {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
|
||||
}
|
||||
} else if (mimeType?.startsWith("video/") == true) {
|
||||
val mediaMetadataRetriever = MediaMetadataRetriever()
|
||||
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
|
||||
|
||||
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
|
||||
|
||||
val blurhash = mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
|
||||
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
|
||||
|
||||
val intArray = IntArray(thumbnail.width * thumbnail.height)
|
||||
thumbnail.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
thumbnail.width,
|
||||
0,
|
||||
0,
|
||||
thumbnail.width,
|
||||
thumbnail.height
|
||||
)
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 9, (9 * (1 / aspectRatio)).roundToInt())
|
||||
} else if (aspectRatio < 1) {
|
||||
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, (9 * aspectRatio).roundToInt(), 9)
|
||||
} else {
|
||||
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if (newDim != "0x0") {
|
||||
Pair(blurhash, newDim)
|
||||
} else {
|
||||
Pair(blurhash, null)
|
||||
}
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent))
|
||||
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
@@ -87,3 +128,79 @@ class FileHeader(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
|
||||
val raw: ByteArray? = getEmbeddedPicture()
|
||||
if (raw != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw))
|
||||
}
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val params = BitmapParams()
|
||||
params.preferredConfig = Bitmap.Config.ARGB_8888
|
||||
|
||||
// Fall back to middle of video
|
||||
// Note: METADATA_KEY_DURATION unit is in ms, not us.
|
||||
val thumbnailTimeUs: Long = (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
|
||||
val width = prepareVideoWidth() ?: return null
|
||||
val height = prepareVideoHeight() ?: return null
|
||||
|
||||
return "${width}x$height"
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
|
||||
val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
return if (widthData.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
widthData.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
|
||||
val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
return if (heightData.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
heightData.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
|
||||
override fun getSize(): Long {
|
||||
return imageData.size.toLong()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
||||
if (position >= imageData.size) {
|
||||
return -1
|
||||
}
|
||||
val newSize = if (position + size > imageData.size) {
|
||||
size - ((position.toInt() + size) - imageData.size)
|
||||
} else {
|
||||
size
|
||||
}
|
||||
|
||||
imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize)
|
||||
|
||||
return newSize
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
object HttpClient {
|
||||
val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L)
|
||||
val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L)
|
||||
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
|
||||
|
||||
var defaultHttpClient: OkHttpClient? = null
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
|
||||
@@ -19,20 +31,60 @@ object HttpClient {
|
||||
}
|
||||
|
||||
fun start(proxy: Proxy?) {
|
||||
this.internalProxy = proxy
|
||||
if (internalProxy != proxy) {
|
||||
this.internalProxy = proxy
|
||||
this.defaultHttpClient = getHttpClient()
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
val seconds = if (internalProxy != null) 20L else 10L
|
||||
fun changeTimeouts(timeout: Duration) {
|
||||
Log.d("HttpClient", "Changing timeout to: $timeout")
|
||||
if (this.defaultTimeout.seconds != timeout.seconds) {
|
||||
this.defaultTimeout = timeout
|
||||
this.defaultHttpClient = getHttpClient()
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClient(timeout: Duration): OkHttpClient {
|
||||
val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(internalProxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.addInterceptor(DefaultContentTypeInterceptor())
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
class DefaultContentTypeInterceptor : Interceptor {
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest: Request = chain.request()
|
||||
val requestWithUserAgent: Request = originalRequest
|
||||
.newBuilder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.build()
|
||||
return chain.proceed(requestWithUserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClientForRelays(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = getHttpClient(defaultTimeout)
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = getHttpClient(defaultTimeout)
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return internalProxy
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import okhttp3.Request
|
||||
|
||||
object Nip96MediaServers {
|
||||
val DEFAULT =
|
||||
listOf(
|
||||
ServerName("Nostr.Build", "https://nostr.build"),
|
||||
ServerName("NostrCheck.me", "https://nostrcheck.me"),
|
||||
ServerName("Nostrage", "https://nostrage.com"),
|
||||
ServerName("Sove", "https://sove.rent"),
|
||||
ServerName("Sovbit", "https://files.sovbit.host"),
|
||||
ServerName("Void.cat", "https://void.cat")
|
||||
)
|
||||
|
||||
data class ServerName(val name: String, val baseUrl: String)
|
||||
|
||||
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
|
||||
|
||||
suspend fun load(url: String): Nip96Retriever.ServerInfo {
|
||||
val cached = cache[url]
|
||||
if (cached != null) return cached
|
||||
|
||||
val fetched = Nip96Retriever().loadInfo(url)
|
||||
cache[url] = fetched
|
||||
return fetched
|
||||
}
|
||||
}
|
||||
|
||||
class Nip96Retriever {
|
||||
data class ServerInfo(
|
||||
@JsonProperty("api_url") val apiUrl: String,
|
||||
@JsonProperty("download_url") val downloadUrl: String? = null,
|
||||
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
|
||||
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("tos_url") val tosUrl: String? = null,
|
||||
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
|
||||
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf()
|
||||
)
|
||||
|
||||
data class Plan(
|
||||
@JsonProperty("name") val name: String? = null,
|
||||
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
|
||||
@JsonProperty("url") val url: String? = null,
|
||||
@JsonProperty("max_byte_size") val maxByteSize: Long? = null,
|
||||
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("media_transformations") val mediaTransformations: Map<MimeType, Array<String>> = emptyMap()
|
||||
)
|
||||
|
||||
fun parse(body: String): ServerInfo {
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, ServerInfo::class.java)
|
||||
}
|
||||
|
||||
suspend fun loadInfo(
|
||||
baseUrl: String
|
||||
): ServerInfo {
|
||||
checkNotInMainThread()
|
||||
|
||||
val request: Request = Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
.newCall(request)
|
||||
.execute().use { response ->
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
return parse(body)
|
||||
} else {
|
||||
throw RuntimeException("Resulting Message from $baseUrl is an error: ${response.code} ${response.message}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias PlanName = String
|
||||
typealias MimeType = String
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
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.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
@@ -38,6 +39,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
// TODO: Migrate this to a property of AccountVi
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
var otherAccounts = listOf<HexKey>()
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
|
||||
@@ -75,6 +77,18 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createOtherAccountsBaseFilter(): TypedFilter? {
|
||||
if (otherAccounts.isEmpty()) return null
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind, ContactListEvent.kind, AdvertisedRelayListEvent.kind, MuteListEvent.kind, PeopleListEvent.kind),
|
||||
authors = otherAccounts.filter { it != account.userProfile().pubkeyHex },
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
@@ -218,7 +232,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
override fun updateChannelFilters() {
|
||||
return if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
accountChannel.typedFilters = listOfNotNull(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createAccountRelayListFilter(),
|
||||
@@ -227,7 +241,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter(),
|
||||
createAccountLastPostsListFilter()
|
||||
createAccountLastPostsListFilter(),
|
||||
createOtherAccountsBaseFilter()
|
||||
).ifEmpty { null }
|
||||
} else {
|
||||
// just the basics.
|
||||
|
||||
@@ -44,9 +44,11 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
)
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter(): TypedFilter {
|
||||
fun createMyChannelsFilter(): TypedFilter? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
if (followingEvents.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
@@ -60,6 +62,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
if (followingEvents.isEmpty()) return null
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
@@ -75,6 +79,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
if (followingEvents.isEmpty()) return null
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
@@ -93,7 +99,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val list = listOf(
|
||||
val list = listOfNotNull(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter(),
|
||||
createMyChannelsFilter()
|
||||
|
||||
@@ -35,7 +35,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) {
|
||||
if (subscriptionId in subscriptions.keys) {
|
||||
val key = "$debugName $subscriptionId ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
@@ -46,6 +46,9 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
consume(event, relay)
|
||||
if (afterEOSE) {
|
||||
markAsEOSE(subscriptionId, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +65,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// }}")
|
||||
|
||||
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[subscriptionId]?.updateEOSE(
|
||||
TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off.
|
||||
relay.url
|
||||
)
|
||||
markAsEOSE(subscriptionId, relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +220,13 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
|
||||
}
|
||||
|
||||
open fun markAsEOSE(subscriptionId: String, relay: Relay) {
|
||||
subscriptions[subscriptionId]?.updateEOSE(
|
||||
TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off.
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
open fun notify(relay: Relay, description: String) = Unit
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
@@ -42,6 +43,54 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
fun createMarketplaceFilter(): List<TypedFilter> {
|
||||
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
|
||||
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
|
||||
val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
|
||||
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(ClassifiedsEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
),
|
||||
hashToLoad?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ClassifiedsEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to it.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
},
|
||||
geohashToLoad?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ClassifiedsEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to it.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamFilter(): List<TypedFilter> {
|
||||
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
|
||||
|
||||
@@ -238,16 +287,19 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
|
||||
listOfNotNull(
|
||||
createLiveStreamTagsFilter(),
|
||||
createLiveStreamGeohashesFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createCommunitiesTagsFilter(),
|
||||
createCommunitiesGeohashesFilter(),
|
||||
createPublicChatsGeohashesFilter()
|
||||
)
|
||||
).ifEmpty { null }
|
||||
discoveryFeedChannel.typedFilters = createLiveStreamFilter()
|
||||
.plus(createPublicChatFilter())
|
||||
.plus(createMarketplaceFilter())
|
||||
.plus(
|
||||
listOfNotNull(
|
||||
createLiveStreamTagsFilter(),
|
||||
createLiveStreamGeohashesFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createCommunitiesTagsFilter(),
|
||||
createCommunitiesGeohashesFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createPublicChatsGeohashesFilter()
|
||||
)
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
@@ -33,7 +34,11 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job = account.scope.launch(Dispatchers.IO) {
|
||||
job = scope.launch(Dispatchers.IO) {
|
||||
// creates cache on main
|
||||
withContext(Dispatchers.Main) {
|
||||
account.userProfile().live()
|
||||
}
|
||||
account.liveHomeFollowLists.collect {
|
||||
if (this@NostrHomeDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
@@ -142,8 +141,17 @@ class ZapPaymentHandler(val account: Account) {
|
||||
} else {
|
||||
launch(Dispatchers.IO) {
|
||||
// Awaits for the event to come back to LocalCache.
|
||||
delay(5000)
|
||||
onProgress(1f)
|
||||
var count = 0
|
||||
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
|
||||
count++
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
if (invoicesToPayOnIntent.isNotEmpty()) {
|
||||
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
|
||||
onProgress(1f)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
@@ -18,21 +18,47 @@ import java.util.UUID
|
||||
*/
|
||||
object Client : RelayPool.Listener {
|
||||
private var listeners = setOf<Listener>()
|
||||
private var relays = Constants.convertDefaultRelays()
|
||||
private var relays = emptyArray<Relay>()
|
||||
private var subscriptions = mapOf<String, List<TypedFilter>>()
|
||||
|
||||
@Synchronized
|
||||
fun connect(relays: Array<Relay>) {
|
||||
fun reconnect(relays: Array<Relay>?, onlyIfChanged: Boolean = false) {
|
||||
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays")
|
||||
checkNotInMainThread()
|
||||
|
||||
RelayPool.register(this)
|
||||
RelayPool.unloadRelays()
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
this.relays = relays
|
||||
if (onlyIfChanged) {
|
||||
if (!isSameRelaySetConfig(relays)) {
|
||||
if (this.relays.isNotEmpty()) {
|
||||
RelayPool.disconnect()
|
||||
RelayPool.unregister(this)
|
||||
RelayPool.unloadRelays()
|
||||
}
|
||||
|
||||
if (relays != null) {
|
||||
RelayPool.register(this)
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
RelayPool.requestAndWatch()
|
||||
this.relays = relays
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.relays.isNotEmpty()) {
|
||||
RelayPool.disconnect()
|
||||
RelayPool.unregister(this)
|
||||
RelayPool.unloadRelays()
|
||||
}
|
||||
|
||||
if (relays != null) {
|
||||
RelayPool.register(this)
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
RelayPool.requestAndWatch()
|
||||
this.relays = relays
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>): Boolean {
|
||||
if (relays.size != newRelayConfig.size) return false
|
||||
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>?): Boolean {
|
||||
if (relays.size != newRelayConfig?.size) return false
|
||||
|
||||
relays.forEach { oldRelayInfo ->
|
||||
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false
|
||||
@@ -99,7 +125,7 @@ object Client : RelayPool.Listener {
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
relay.connectAndRun {
|
||||
@@ -126,18 +152,12 @@ object Client : RelayPool.Listener {
|
||||
subscriptions = subscriptions.minus(subscriptionId)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
RelayPool.unregister(this)
|
||||
RelayPool.disconnect()
|
||||
RelayPool.unloadRelays()
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +225,7 @@ object Client : RelayPool.Listener {
|
||||
/**
|
||||
* A new message was received
|
||||
*/
|
||||
open fun onEvent(event: Event, subscriptionId: String, relay: Relay) = Unit
|
||||
open fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) = Unit
|
||||
|
||||
/**
|
||||
* A new or repeat message was received
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
@@ -11,7 +10,7 @@ object Constants {
|
||||
|
||||
fun convertDefaultRelays(): Array<Relay> {
|
||||
return defaultRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy())
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
@@ -51,13 +50,13 @@ object Constants {
|
||||
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", 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://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 }
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
@@ -9,13 +10,10 @@ import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.events.bytesUsedInMemory
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
enum class FeedType {
|
||||
@@ -28,22 +26,14 @@ class Relay(
|
||||
var url: String,
|
||||
var read: Boolean = true,
|
||||
var write: Boolean = true,
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
|
||||
proxy: Proxy?
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet()
|
||||
) {
|
||||
companion object {
|
||||
// waits 3 minutes to reconnect once things fail
|
||||
const val RECONNECTING_IN_SECONDS = 60 * 3
|
||||
}
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
|
||||
.connectTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
|
||||
.writeTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
private val httpClient = HttpClient.getHttpClientForRelays()
|
||||
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
@@ -87,7 +77,7 @@ class Relay(
|
||||
private var connectingBlock = AtomicBoolean()
|
||||
|
||||
fun connectAndRun(onConnected: (Relay) -> Unit) {
|
||||
Log.d("Client", "Relay.connect $url")
|
||||
Log.d("Relay", "Relay.connect $url")
|
||||
// BRB is crashing OkHttp Deflater object :(
|
||||
if (url.contains("brb.io")) return
|
||||
|
||||
@@ -120,6 +110,7 @@ class Relay(
|
||||
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
checkNotInMainThread()
|
||||
Log.d("Relay", "Connect onOpen $url $socket")
|
||||
|
||||
markConnectionAsReady(
|
||||
pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis,
|
||||
@@ -150,6 +141,8 @@ class Relay(
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
Log.w("Relay", "Relay onClosing $url: $reason")
|
||||
|
||||
listeners.forEach {
|
||||
it.onRelayStateChange(
|
||||
this@Relay,
|
||||
@@ -164,6 +157,8 @@ class Relay(
|
||||
|
||||
markConnectionAsClosed()
|
||||
|
||||
Log.w("Relay", "Relay onClosed $url: $reason")
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) }
|
||||
}
|
||||
|
||||
@@ -172,7 +167,7 @@ class Relay(
|
||||
|
||||
errorCounter++
|
||||
|
||||
socket?.close(1000, "Normal close")
|
||||
socket?.cancel() // 1000, "Normal close"
|
||||
// Failures disconnect the relay.
|
||||
markConnectionAsClosed()
|
||||
|
||||
@@ -209,10 +204,7 @@ class Relay(
|
||||
|
||||
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
|
||||
listeners.forEach {
|
||||
it.onEvent(this@Relay, subscriptionId, event)
|
||||
if (afterEOSEPerSubscription[subscriptionId] == true) {
|
||||
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
|
||||
}
|
||||
it.onEvent(this@Relay, subscriptionId, event, afterEOSEPerSubscription[subscriptionId] == true)
|
||||
}
|
||||
}
|
||||
"EOSE" -> listeners.forEach {
|
||||
@@ -267,7 +259,7 @@ class Relay(
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
Log.d("Client", "Relay.disconnect $url")
|
||||
Log.d("Relay", "Relay.disconnect $url")
|
||||
checkNotInMainThread()
|
||||
|
||||
closingTimeInSeconds = TimeUtils.now()
|
||||
@@ -393,7 +385,7 @@ class Relay(
|
||||
/**
|
||||
* A new message was received
|
||||
*/
|
||||
fun onEvent(relay: Relay, subscriptionId: String, event: Event)
|
||||
fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean)
|
||||
|
||||
fun onError(relay: Relay, subscriptionId: String, error: Error)
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ object RelayPool : Relay.Listener {
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onEvent(event: Event, subscriptionId: String, relay: Relay)
|
||||
fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean)
|
||||
|
||||
fun onError(error: Error, subscriptionId: String, relay: Relay)
|
||||
|
||||
@@ -116,8 +116,8 @@ object RelayPool : Relay.Listener {
|
||||
fun onNotify(relay: Relay, description: String)
|
||||
}
|
||||
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean) {
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
|
||||
}
|
||||
|
||||
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
|
||||
@@ -222,6 +223,14 @@ class MainActivity : AppCompatActivity() {
|
||||
changedNetwork = true
|
||||
}
|
||||
|
||||
if (changedNetwork) {
|
||||
if (isOnMobileData) {
|
||||
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE)
|
||||
} else {
|
||||
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI)
|
||||
}
|
||||
}
|
||||
|
||||
return changedNetwork
|
||||
}
|
||||
|
||||
@@ -252,9 +261,7 @@ class MainActivity : AppCompatActivity() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Log.d("ServiceManager NetworkCallback", "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}")
|
||||
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
serviceManager.forceRestart()
|
||||
}
|
||||
serviceManager.forceRestart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toFile
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.Response
|
||||
import okio.BufferedSink
|
||||
import okio.IOException
|
||||
import okio.source
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
class ImageUploader(val account: Account?) {
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
server: ServersAvailable,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: kotlin.runCatching {
|
||||
uri.toFile().length()
|
||||
}.getOrNull() ?: 0
|
||||
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
val myServer = when (server) {
|
||||
// ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
|
||||
// ImgurServer()
|
||||
// }
|
||||
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
|
||||
NostrImgServer()
|
||||
}
|
||||
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
|
||||
NostrBuildServer()
|
||||
}
|
||||
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
|
||||
NostrFilesDevServer()
|
||||
}
|
||||
ServersAvailable.NOSTRCHECK_ME, ServersAvailable.NOSTRCHECK_ME_NIP_94 -> {
|
||||
NostrCheckMeServer()
|
||||
}
|
||||
else -> {
|
||||
NostrBuildServer()
|
||||
}
|
||||
}
|
||||
|
||||
uploadImage(imageInputStream, length, myContentType, myServer, onSuccess, onError)
|
||||
}
|
||||
|
||||
fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
length: Long,
|
||||
contentType: String?,
|
||||
server: FileServer,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val fileName = randomChars()
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
server.inputParameterName(contentType),
|
||||
"$fileName.$extension",
|
||||
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
override fun contentLength() = length
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
server.authorizationToken(account, requestBody.toString()) { authorizationToken ->
|
||||
if (authorizationToken != null) {
|
||||
requestBuilder.addHeader("Authorization", authorizationToken)
|
||||
}
|
||||
|
||||
requestBuilder
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.postUrl(contentType))
|
||||
.post(requestBody)
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val url = server.parseUrlFromSuccess(body.string(), authorizationToken)
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
|
||||
onSuccess(url, contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun NIP98Header(url: String, method: String, body: String, onReady: (String?) -> Unit) {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount == null) {
|
||||
onReady(null)
|
||||
return
|
||||
}
|
||||
|
||||
myAccount.createHTTPAuthorization(url, method, body) {
|
||||
val noteJson = it.toJson()
|
||||
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
|
||||
onReady("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class FileServer {
|
||||
abstract fun postUrl(contentType: String?): String
|
||||
abstract fun parseUrlFromSuccess(body: String, authorizationToken: String?): String?
|
||||
abstract fun inputParameterName(contentType: String?): String
|
||||
|
||||
open fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
|
||||
onReady(null)
|
||||
}
|
||||
}
|
||||
|
||||
class NostrImgServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
|
||||
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
}
|
||||
|
||||
class ImgurServer : FileServer() {
|
||||
override fun postUrl(contentType: String?): String {
|
||||
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
}
|
||||
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
|
||||
onReady("Client-ID e6aea87296f3f96")
|
||||
}
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files"
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val data = tree?.get("data")
|
||||
val data0 = data?.get(0)
|
||||
val url = data0?.get("url")
|
||||
return url.toString().replace("\"", "")
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
|
||||
ImageUploader(account).NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
class NostrFilesDevServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
return tree?.get("url")?.asText()
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
class NostrCheckMeServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media"
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
checkNotInMainThread()
|
||||
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("url")?.asText()
|
||||
val id = tree?.get("id")?.asText()
|
||||
var isCompleted = false
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
val requrl =
|
||||
"https://nostrcheck.me/api/v1/media?id=$id" // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
|
||||
|
||||
val requestBuilder = Request.Builder().url(requrl)
|
||||
|
||||
if (authorizationToken != null) {
|
||||
requestBuilder.addHeader("Authorization", authorizationToken)
|
||||
}
|
||||
|
||||
val request = requestBuilder.get().build()
|
||||
|
||||
while (!isCompleted) {
|
||||
client.newCall(request).execute().use {
|
||||
val tree = jacksonObjectMapper().readTree(it.body.string())
|
||||
isCompleted = tree?.get("status")?.asText() == "completed"
|
||||
try {
|
||||
Thread.sleep(500)
|
||||
} catch (e: InterruptedException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "mediafile"
|
||||
}
|
||||
|
||||
override fun authorizationToken(account: Account?, body: String, onReady: (String?) -> Unit) {
|
||||
ImageUploader(account).NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body, onReady)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -19,6 +19,11 @@ import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ServerOption(
|
||||
val server: Nip96MediaServers.ServerName,
|
||||
val isNip95: Boolean
|
||||
)
|
||||
|
||||
@Stable
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
@@ -27,7 +32,7 @@ open class NewMediaModel : ViewModel() {
|
||||
val imageUploadingError = MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
var mediaType by mutableStateOf<String?>(null)
|
||||
|
||||
var selectedServer by mutableStateOf<ServersAvailable?>(null)
|
||||
var selectedServer by mutableStateOf<ServerOption?>(null)
|
||||
var alt by mutableStateOf("")
|
||||
var sensitiveContent by mutableStateOf(false)
|
||||
|
||||
@@ -43,20 +48,7 @@ open class NewMediaModel : ViewModel() {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
this.mediaType = contentType
|
||||
this.selectedServer = defaultServer()
|
||||
|
||||
// if (selectedServer == ServersAvailable.IMGUR) {
|
||||
// selectedServer = ServersAvailable.IMGUR_NIP_94
|
||||
// } else
|
||||
if (selectedServer == ServersAvailable.NOSTRIMG) {
|
||||
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
|
||||
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
|
||||
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRCHECK_ME) {
|
||||
selectedServer = ServersAvailable.NOSTRCHECK_ME_NIP_94
|
||||
}
|
||||
this.selectedServer = ServerOption(defaultServer(), false)
|
||||
}
|
||||
|
||||
fun upload(context: Context, relayList: List<Relay>? = null) {
|
||||
@@ -76,8 +68,7 @@ open class NewMediaModel : ViewModel() {
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
|
||||
if (selectedServer == ServersAvailable.NIP95) {
|
||||
if (serverToUse.isNip95) {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Loading"
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
@@ -95,30 +86,35 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Uploading"
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
relayList = relayList
|
||||
)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
try {
|
||||
val result = Nip96Uploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = serverToUse.server,
|
||||
contentResolver = contentResolver,
|
||||
onProgress = { percent: Float ->
|
||||
uploadingPercentage.value = 0.2f + (0.2f * percent)
|
||||
}
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
relayList = relayList
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload: ${e.message}")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -142,63 +138,83 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingPercentage.value = 0.0f
|
||||
|
||||
alt = ""
|
||||
selectedServer = account?.defaultFileServer
|
||||
selectedServer = ServerOption(defaultServer(), false)
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
localContentType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<Relay>? = null
|
||||
) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
|
||||
val originalHash = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null }
|
||||
val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null }
|
||||
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
imageUrl,
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(it, relayList) {
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
FileHeader.prepare(
|
||||
data = imageData,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(imageUrl, magnet, it, alt, sensitiveContent, originalHash, relayList) {
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
onceUploaded()
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,17 +226,15 @@ open class NewMediaModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
null,
|
||||
onReady = {
|
||||
uploadingDescription.value = "Signing"
|
||||
uploadingPercentage.value = 0.40f
|
||||
account?.createNip95(bytes, headerInfo = it) { nip95 ->
|
||||
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.sendNip95(nip95.first, nip95.second, relayList)
|
||||
account?.consumeAndSendNip95(nip95.first, nip95.second, relayList)
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
@@ -243,7 +257,7 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun defaultServer() = account?.defaultFileServer
|
||||
fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
@@ -146,7 +146,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context, relayList)
|
||||
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
|
||||
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it.server) }
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
@@ -170,26 +170,19 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
}
|
||||
}
|
||||
|
||||
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
|
||||
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
// selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTRCHECK_ME_NIP_94
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) {
|
||||
val fileServers = listOf(
|
||||
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf(
|
||||
ServerOption(
|
||||
Nip96MediaServers.ServerName(
|
||||
"NIP95",
|
||||
stringResource(id = R.string.upload_server_relays_nip95)
|
||||
),
|
||||
true
|
||||
)
|
||||
)
|
||||
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() }
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
Row(
|
||||
@@ -252,10 +245,12 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second,
|
||||
placeholder = fileServers.firstOrNull {
|
||||
it.server == accountViewModel.account.defaultFileServer
|
||||
}?.server?.name ?: fileServers[0].server.name,
|
||||
options = fileServerOptions,
|
||||
onSelect = {
|
||||
postViewModel.selectedServer = fileServers[it].first
|
||||
postViewModel.selectedServer = fileServers[it]
|
||||
},
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
@@ -263,44 +258,40 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
)
|
||||
}
|
||||
|
||||
if (isNIP94Server(postViewModel.selectedServer) ||
|
||||
postViewModel.selectedServer == ServersAvailable.NIP95
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
SettingSwitchItem(
|
||||
checked = postViewModel.sensitiveContent,
|
||||
onCheckedChange = { postViewModel.sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description
|
||||
)
|
||||
}
|
||||
SettingSwitchItem(
|
||||
checked = postViewModel.sensitiveContent,
|
||||
onCheckedChange = { postViewModel.sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = postViewModel.alt,
|
||||
onValueChange = { postViewModel.alt = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = postViewModel.alt,
|
||||
onValueChange = { postViewModel.alt = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.filled.LocationOff
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Sell
|
||||
import androidx.compose.material.icons.filled.ShowChart
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
@@ -93,6 +94,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
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.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
@@ -110,8 +112,8 @@ import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
@@ -152,6 +154,7 @@ import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -345,6 +348,15 @@ fun NewPostView(
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsProduct) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
SellProduct(postViewModel = postViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
MessageField(postViewModel)
|
||||
|
||||
if (postViewModel.wantsPoll) {
|
||||
@@ -392,8 +404,8 @@ fun NewPostView(
|
||||
url,
|
||||
accountViewModel.account.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, server, context, relayList)
|
||||
accountViewModel.account.changeDefaultFileServer(server)
|
||||
postViewModel.upload(url, alt, sensitiveContent, false, server, context, relayList)
|
||||
accountViewModel.account.changeDefaultFileServer(server.server)
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
@@ -448,8 +460,14 @@ fun NewPostView(
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl =
|
||||
val removedParamsFromUrl = if (myUrlPreview.contains("?")) {
|
||||
myUrlPreview.split("?")[0].lowercase()
|
||||
} else if (myUrlPreview.contains("#")) {
|
||||
myUrlPreview.split("#")[0].lowercase()
|
||||
} else {
|
||||
myUrlPreview
|
||||
}
|
||||
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
@@ -530,6 +548,16 @@ fun NewPostView(
|
||||
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
if (postViewModel.wantsPoll) {
|
||||
postViewModel.wantsProduct = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddClassifiedsButton(postViewModel) {
|
||||
postViewModel.wantsProduct = !postViewModel.wantsProduct
|
||||
if (postViewModel.wantsProduct) {
|
||||
postViewModel.wantsPoll = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,7 +663,11 @@ private fun MessageField(
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.what_s_on_your_mind),
|
||||
text = if (postViewModel.wantsProduct) {
|
||||
stringResource(R.string.description)
|
||||
} else {
|
||||
stringResource(R.string.what_s_on_your_mind)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
@@ -776,6 +808,227 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SellProduct(postViewModel: NewPostViewModel) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_title),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
MyTextField(
|
||||
value = postViewModel.title,
|
||||
onValueChange = {
|
||||
postViewModel.title = it
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_title_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
visualTransformation = UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_price),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
MyTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.price,
|
||||
onValueChange = {
|
||||
runCatching {
|
||||
if (it.text.isEmpty()) {
|
||||
postViewModel.price = TextFieldValue("")
|
||||
} else if (it.text.toLongOrNull() != null) {
|
||||
postViewModel.price = it
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "1000",
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_condition),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
val conditionTypes = listOf(
|
||||
Triple(ClassifiedsEvent.CONDITION.NEW, stringResource(id = R.string.classifieds_condition_new), stringResource(id = R.string.classifieds_condition_new_explainer)),
|
||||
Triple(ClassifiedsEvent.CONDITION.USED_LIKE_NEW, stringResource(id = R.string.classifieds_condition_like_new), stringResource(id = R.string.classifieds_condition_like_new_explainer)),
|
||||
Triple(ClassifiedsEvent.CONDITION.USED_GOOD, stringResource(id = R.string.classifieds_condition_good), stringResource(id = R.string.classifieds_condition_good_explainer)),
|
||||
Triple(ClassifiedsEvent.CONDITION.USED_FAIR, stringResource(id = R.string.classifieds_condition_fair), stringResource(id = R.string.classifieds_condition_fair_explainer))
|
||||
)
|
||||
|
||||
val conditionOptions = remember { conditionTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
|
||||
TextSpinner(
|
||||
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
|
||||
options = conditionOptions,
|
||||
onSelect = {
|
||||
postViewModel.condition = conditionTypes[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp, bottom = 1.dp)
|
||||
) { currentOption, modifier ->
|
||||
MyTextField(
|
||||
value = TextFieldValue(currentOption),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_category),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
val categoryList = listOf(
|
||||
R.string.classifieds_category_clothing,
|
||||
R.string.classifieds_category_accessories,
|
||||
R.string.classifieds_category_electronics,
|
||||
R.string.classifieds_category_furniture,
|
||||
R.string.classifieds_category_collectibles,
|
||||
R.string.classifieds_category_books,
|
||||
R.string.classifieds_category_pets,
|
||||
R.string.classifieds_category_sports,
|
||||
R.string.classifieds_category_fitness,
|
||||
R.string.classifieds_category_art,
|
||||
R.string.classifieds_category_crafts,
|
||||
R.string.classifieds_category_home,
|
||||
R.string.classifieds_category_office,
|
||||
R.string.classifieds_category_food,
|
||||
R.string.classifieds_category_misc,
|
||||
R.string.classifieds_category_other
|
||||
)
|
||||
|
||||
val categoryTypes = categoryList.map {
|
||||
Triple(it, stringResource(id = it), null)
|
||||
}
|
||||
|
||||
val categoryOptions = remember { categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() }
|
||||
TextSpinner(
|
||||
placeholder = categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second ?: "",
|
||||
options = categoryOptions,
|
||||
onSelect = {
|
||||
postViewModel.category = TextFieldValue(categoryTypes[it].second)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp, bottom = 1.dp)
|
||||
) { currentOption, modifier ->
|
||||
MyTextField(
|
||||
value = TextFieldValue(currentOption),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_location),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
MyTextField(
|
||||
value = postViewModel.locationText,
|
||||
onValueChange = {
|
||||
postViewModel.locationText = it
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_location_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
visualTransformation = UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) {
|
||||
Column(
|
||||
@@ -862,11 +1115,11 @@ fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewMo
|
||||
onValueChange = {
|
||||
postViewModel.updateZapForwardTo(it)
|
||||
},
|
||||
label = { Text(text = stringResource(R.string.zap_split_serarch_and_add_user)) },
|
||||
label = { Text(text = stringResource(R.string.zap_split_search_and_add_user)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.zap_split_serarch_and_add_user_placeholder),
|
||||
text = stringResource(R.string.zap_split_search_and_add_user_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
@@ -1213,6 +1466,36 @@ private fun ForwardZapTo(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddClassifiedsButton(
|
||||
postViewModel: NewPostViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
if (!postViewModel.wantsProduct) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Sell,
|
||||
contentDescription = stringResource(R.string.classifieds),
|
||||
modifier = Modifier
|
||||
.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Sell,
|
||||
contentDescription = stringResource(id = R.string.classifieds),
|
||||
modifier = Modifier
|
||||
.size(20.dp),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkAsSensitive(
|
||||
postViewModel: NewPostViewModel,
|
||||
@@ -1320,8 +1603,8 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
|
||||
@Composable
|
||||
fun ImageVideoDescription(
|
||||
uri: Uri,
|
||||
defaultServer: ServersAvailable,
|
||||
onAdd: (String, ServersAvailable, Boolean) -> Unit,
|
||||
defaultServer: Nip96MediaServers.ServerName,
|
||||
onAdd: (String, ServerOption, Boolean) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
@@ -1332,23 +1615,19 @@ fun ImageVideoDescription(
|
||||
val isImage = mediaType.startsWith("image")
|
||||
val isVideo = mediaType.startsWith("video")
|
||||
|
||||
val fileServers = listOf(
|
||||
// Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
|
||||
Triple(ServersAvailable.NOSTRCHECK_ME, stringResource(id = R.string.upload_server_nostrcheckme), stringResource(id = R.string.upload_server_nostrcheckme_explainer)),
|
||||
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf(
|
||||
ServerOption(
|
||||
Nip96MediaServers.ServerName(
|
||||
"NIP95",
|
||||
stringResource(id = R.string.upload_server_relays_nip95)
|
||||
),
|
||||
true
|
||||
)
|
||||
)
|
||||
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() }
|
||||
|
||||
var selectedServer by remember { mutableStateOf(defaultServer) }
|
||||
var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) }
|
||||
var message by remember { mutableStateOf("") }
|
||||
var sensitiveContent by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -1458,10 +1737,12 @@ fun ImageVideoDescription(
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
|
||||
placeholder = fileServers.firstOrNull {
|
||||
it.server == accountViewModel.account.defaultFileServer
|
||||
}?.server?.name ?: fileServers[0].server.name,
|
||||
options = fileServerOptions,
|
||||
onSelect = {
|
||||
selectedServer = fileServers[it].first
|
||||
selectedServer = fileServers[it]
|
||||
},
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
@@ -1469,45 +1750,41 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
|
||||
if (isNIP94Server(selectedServer) ||
|
||||
selectedServer == ServersAvailable.NIP95
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
SettingSwitchItem(
|
||||
checked = sensitiveContent,
|
||||
onCheckedChange = { sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description
|
||||
)
|
||||
}
|
||||
SettingSwitchItem(
|
||||
checked = sensitiveContent,
|
||||
onCheckedChange = { sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
|
||||
@@ -18,7 +18,6 @@ import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.LocationUtil
|
||||
@@ -33,16 +32,23 @@ import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.events.findURLs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLEncoder
|
||||
|
||||
enum class UserSuggestionAnchor {
|
||||
MAIN_MESSAGE,
|
||||
@@ -61,6 +67,9 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var mentions by mutableStateOf<List<User>?>(null)
|
||||
var replyTos by mutableStateOf<List<Note>?>(null)
|
||||
|
||||
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
@@ -94,6 +103,14 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var isValidConsensusThreshold = mutableStateOf(true)
|
||||
var isValidClosedAt = mutableStateOf(true)
|
||||
|
||||
// Classifieds
|
||||
var wantsProduct by mutableStateOf(false)
|
||||
var title by mutableStateOf(TextFieldValue(""))
|
||||
var price by mutableStateOf(TextFieldValue(""))
|
||||
var locationText by mutableStateOf(TextFieldValue(""))
|
||||
var category by mutableStateOf(TextFieldValue(""))
|
||||
var condition by mutableStateOf<ClassifiedsEvent.CONDITION>(ClassifiedsEvent.CONDITION.USED_LIKE_NEW)
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
@@ -209,11 +226,25 @@ open class NewPostViewModel() : ViewModel() {
|
||||
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
|
||||
|
||||
nip95attachments.forEach {
|
||||
if (replyTos?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) {
|
||||
account?.sendNip95(it.first, it.second, relayList)
|
||||
}
|
||||
}
|
||||
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = nip94attachments.filter {
|
||||
it.urls().intersect(urls).isNotEmpty()
|
||||
}
|
||||
usedAttachments.forEach {
|
||||
account?.sendHeader(it, relayList, { })
|
||||
}
|
||||
|
||||
if (originalNote?.channelHex() != null) {
|
||||
if (originalNote is AddressableEvent && originalNote?.address() != null) {
|
||||
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
|
||||
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
|
||||
} else {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
|
||||
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
|
||||
}
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
|
||||
@@ -271,7 +302,26 @@ open class NewPostViewModel() : ViewModel() {
|
||||
wantsToMarkAsSensitive,
|
||||
localZapRaiserAmount,
|
||||
relayList,
|
||||
geoHash
|
||||
geoHash,
|
||||
nip94attachments = usedAttachments
|
||||
)
|
||||
} else if (wantsProduct) {
|
||||
account?.sendClassifieds(
|
||||
title = title.text,
|
||||
price = Price(price.text, "SATS", null),
|
||||
condition = condition,
|
||||
message = tagger.message,
|
||||
replyTo = tagger.replyTos,
|
||||
mentions = tagger.mentions,
|
||||
location = locationText.text,
|
||||
category = category.text,
|
||||
directMentions = tagger.directMentions,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments
|
||||
)
|
||||
} else {
|
||||
// adds markers
|
||||
@@ -295,7 +345,8 @@ open class NewPostViewModel() : ViewModel() {
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions,
|
||||
relayList = relayList,
|
||||
geohash = geoHash
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -303,7 +354,15 @@ open class NewPostViewModel() : ViewModel() {
|
||||
cancel()
|
||||
}
|
||||
|
||||
fun upload(galleryUri: Uri, alt: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
|
||||
fun upload(
|
||||
galleryUri: Uri,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
isPrivate: Boolean = false,
|
||||
server: ServerOption,
|
||||
context: Context,
|
||||
relayList: List<Relay>? = null
|
||||
) {
|
||||
isUploadingImage = true
|
||||
contentToAddUrl = null
|
||||
|
||||
@@ -316,34 +375,50 @@ open class NewPostViewModel() : ViewModel() {
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server == ServersAvailable.NIP95) {
|
||||
if (server.isNip95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
if (isNIP94Server(server)) {
|
||||
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent)
|
||||
} else {
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n" + imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
try {
|
||||
val result = Nip96Uploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server.server,
|
||||
contentResolver = contentResolver,
|
||||
onProgress = { }
|
||||
)
|
||||
|
||||
if (!isPrivate) {
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent
|
||||
)
|
||||
} else {
|
||||
noNIP94(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"ImageUploader",
|
||||
"Failed to upload ${e.message}",
|
||||
e
|
||||
)
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -381,6 +456,10 @@ open class NewPostViewModel() : ViewModel() {
|
||||
wantsZapraiser = false
|
||||
zapRaiserAmount = null
|
||||
|
||||
wantsProduct = false
|
||||
condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW
|
||||
price = TextFieldValue("")
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
wantsToAddGeoHash = false
|
||||
@@ -527,6 +606,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
(!wantsZapraiser || zapRaiserAmount != null) &&
|
||||
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
|
||||
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
|
||||
(!wantsProduct || (!title.text.isNullOrBlank() && !price.text.isNullOrBlank() && !category.text.isNullOrBlank())) &&
|
||||
contentToAddUrl == null
|
||||
}
|
||||
|
||||
@@ -543,44 +623,131 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onReady = {
|
||||
account?.sendHeader(it, relayList = relayList) { note ->
|
||||
isUploadingImage = false
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
|
||||
val originalHash = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null }
|
||||
val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null }
|
||||
|
||||
message = TextFieldValue(message.text + "\nnostr:" + note.toNEvent())
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
FileHeader.prepare(
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
nip94attachments = nip94attachments + event
|
||||
val contentWarning = if (sensitiveContent) "" else null
|
||||
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun noNIP94(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
|
||||
val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Server failed to return a url")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
onReady = { header: FileHeader ->
|
||||
isUploadingImage = false
|
||||
val contentWarning = if (sensitiveContent) "" else null
|
||||
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun addInlineMetadataAsNIP54(
|
||||
imageUrl: String,
|
||||
dim: String?,
|
||||
m: String?,
|
||||
alt: String?,
|
||||
blurHash: String?,
|
||||
x: String?,
|
||||
sensitiveContent: String?
|
||||
): String {
|
||||
val extension = listOfNotNull(
|
||||
m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" },
|
||||
dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" },
|
||||
alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" },
|
||||
blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" },
|
||||
x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" },
|
||||
sensitiveContent?.ifBlank { null }?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" }
|
||||
).joinToString("&")
|
||||
|
||||
return if (imageUrl.contains("#")) {
|
||||
"$imageUrl&$extension"
|
||||
} else {
|
||||
"$imageUrl#$extension"
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
fun createNIP95Record(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
null,
|
||||
onReady = {
|
||||
account?.createNip95(bytes, headerInfo = it) { nip95 ->
|
||||
val note = nip95.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) }
|
||||
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
|
||||
@@ -32,10 +32,9 @@ class NewRelayListViewModel : ViewModel() {
|
||||
relays.let {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.saveRelayList(it.value)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
clear()
|
||||
}
|
||||
|
||||
fun loadRelayDocuments() {
|
||||
@@ -120,7 +119,7 @@ class NewRelayListViewModel : ViewModel() {
|
||||
|
||||
fun deleteAll() {
|
||||
_relays.update { relays ->
|
||||
relays.dropWhile { relays.isNotEmpty() }
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-11
@@ -184,23 +184,35 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = account.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
try {
|
||||
val result = Nip96Uploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = account.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
onProgress = { }
|
||||
)
|
||||
|
||||
val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
|
||||
if (url != null) {
|
||||
onUploading(false)
|
||||
onUploaded(imageUrl)
|
||||
},
|
||||
onError = {
|
||||
onUploaded(url)
|
||||
} else {
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toFile
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
|
||||
class Nip96Uploader(val account: Account?) {
|
||||
|
||||
suspend fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96MediaServers.ServerName,
|
||||
contentResolver: ContentResolver,
|
||||
onProgress: (percentage: Float) -> Unit
|
||||
): PartialEvent {
|
||||
val serverInfo = Nip96Retriever().loadInfo(
|
||||
server.baseUrl
|
||||
)
|
||||
|
||||
return uploadImage(uri, contentType, size, alt, sensitiveContent, serverInfo, contentResolver, onProgress)
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
contentResolver: ContentResolver,
|
||||
onProgress: (percentage: Float) -> Unit
|
||||
): PartialEvent {
|
||||
checkNotInMainThread()
|
||||
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: kotlin.runCatching {
|
||||
uri.toFile().length()
|
||||
}.getOrNull() ?: 0
|
||||
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
|
||||
return uploadImage(imageInputStream, length, myContentType, alt, sensitiveContent, server, onProgress)
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
length: Long,
|
||||
contentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
onProgress: (percentage: Float) -> Unit
|
||||
): PartialEvent {
|
||||
checkNotInMainThread()
|
||||
|
||||
val fileName = randomChars()
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("expiration", "")
|
||||
.addFormDataPart("size", length.toString())
|
||||
.also { body ->
|
||||
alt?.let { body.addFormDataPart("alt", it) }
|
||||
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
|
||||
contentType?.let { body.addFormDataPart("content_type", it) }
|
||||
}
|
||||
.addFormDataPart(
|
||||
"file",
|
||||
"$fileName.$extension",
|
||||
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
override fun contentLength() = length
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
nip98Header(server.apiUrl)?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
requestBuilder
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.apiUrl)
|
||||
.post(requestBody)
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body.use { body ->
|
||||
val str = body.string()
|
||||
val result = parseResults(str)
|
||||
|
||||
if (!result.processingUrl.isNullOrBlank()) {
|
||||
return waitProcessing(result, server, onProgress)
|
||||
} else if (result.status == "success" && result.nip94Event != null) {
|
||||
return result.nip94Event
|
||||
} else {
|
||||
throw RuntimeException("Failed to upload with message: ${result.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw RuntimeException("Error Uploading image: ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
server: Nip96Retriever.ServerInfo
|
||||
): Boolean {
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
nip98Header(server.apiUrl)?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
|
||||
|
||||
val request = requestBuilder
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
|
||||
.delete()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body.use { body ->
|
||||
val str = body.string()
|
||||
val result = parseDeleteResults(str)
|
||||
return result.status == "success"
|
||||
}
|
||||
} else {
|
||||
throw RuntimeException("Error Uploading image: ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitProcessing(
|
||||
result: Nip96Result,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
onProgress: (percentage: Float) -> Unit
|
||||
): PartialEvent {
|
||||
val client = HttpClient.getHttpClient()
|
||||
var currentResult = result
|
||||
|
||||
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
||||
onProgress((currentResult.percentage ?: 100) / 100f)
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(result.processingUrl)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
it.body.use {
|
||||
currentResult = parseResults(it.string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delay(500)
|
||||
}
|
||||
onProgress((currentResult.percentage ?: 100) / 100f)
|
||||
|
||||
val nip94 = currentResult.nip94Event
|
||||
|
||||
if (nip94 != null) {
|
||||
return nip94
|
||||
} else {
|
||||
throw RuntimeException("Error waiting for processing. Final result is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nip98Header(url: String): String? {
|
||||
return withTimeoutOrNull(5000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
nip98Header(url, "POST") { authorizationToken ->
|
||||
continuation.resume(authorizationToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun nip98Header(url: String, method: String, file: ByteArray? = null, onReady: (String?) -> Unit) {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount == null) {
|
||||
onReady(null)
|
||||
return
|
||||
}
|
||||
|
||||
myAccount.createHTTPAuthorization(url, method, file) {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||
onReady("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
|
||||
data class DeleteResult(
|
||||
val status: String,
|
||||
val message: String
|
||||
)
|
||||
|
||||
private fun parseDeleteResults(body: String): DeleteResult {
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, DeleteResult::class.java)
|
||||
}
|
||||
|
||||
data class Nip96Result(
|
||||
val status: String,
|
||||
val message: String? = null,
|
||||
@JsonProperty("processing_url")
|
||||
val processingUrl: String? = null,
|
||||
val percentage: Int? = null,
|
||||
@JsonProperty("nip94_event")
|
||||
val nip94Event: PartialEvent? = null
|
||||
)
|
||||
|
||||
class PartialEvent(
|
||||
val tags: Array<Array<String>>? = null,
|
||||
val content: String? = null
|
||||
)
|
||||
|
||||
private fun parseResults(body: String): Nip96Result {
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, Nip96Result::class.java)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -21,7 +26,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -34,21 +38,40 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.fasterxml.jackson.databind.node.TextNode
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.service.CashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.CashuToken
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.note.CashuIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.CopyIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
||||
var cachuData by remember { mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading<CashuToken>()) }
|
||||
var cachuData by remember {
|
||||
mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading())
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = cashutoken) {
|
||||
launch(Dispatchers.IO) {
|
||||
@@ -59,7 +82,7 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = cachuData) {
|
||||
Crossfade(targetState = cachuData, label = "CashuPreview(") {
|
||||
when (it) {
|
||||
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
|
||||
is GenericLoadable.Error<CashuToken> -> Text(
|
||||
@@ -73,26 +96,58 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
|
||||
val lud16 = remember(accountViewModel) {
|
||||
accountViewModel.account.userProfile().info?.lud16
|
||||
}
|
||||
CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast)
|
||||
}
|
||||
|
||||
val useWebService = false
|
||||
@Composable
|
||||
@Preview()
|
||||
fun CashuPreviewPreview() {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateTheme(ThemeType.DARK)
|
||||
|
||||
AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) {
|
||||
Column() {
|
||||
CashuPreview(
|
||||
token = CashuToken("token", "mint", 32400, TextNode("")),
|
||||
melt = { token, context, onDone ->
|
||||
},
|
||||
toast = { title, message ->
|
||||
}
|
||||
)
|
||||
|
||||
CashuPreviewNew(
|
||||
token = CashuToken("token", "mint", 32400, TextNode("")),
|
||||
melt = { token, context, onDone ->
|
||||
},
|
||||
toast = { title, message ->
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(
|
||||
token: CashuToken,
|
||||
melt: (CashuToken, Context, (String, String) -> Unit) -> Unit,
|
||||
toast: (String, String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -117,80 +172,195 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
|
||||
|
||||
Divider()
|
||||
|
||||
token.totalAmount.let {
|
||||
Text(
|
||||
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
var isRedeeming by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
isRedeeming = true
|
||||
melt(token, context) { title, message ->
|
||||
toast(title, message)
|
||||
isRedeeming = false
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
if (isRedeeming) {
|
||||
LoadingAnimation()
|
||||
} else {
|
||||
ZapIcon(Size20dp, tint = Color.White)
|
||||
}
|
||||
Spacer(DoubleHorzSpacer)
|
||||
|
||||
Text(
|
||||
stringResource(id = R.string.cashu_redeem_to_zap),
|
||||
color = Color.White,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Button(
|
||||
onClick = {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}"))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
startActivity(context, intent, null)
|
||||
} catch (e: Exception) {
|
||||
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
CashuIcon(Size20Modifier)
|
||||
Spacer(DoubleHorzSpacer)
|
||||
Text(stringResource(id = R.string.cashu_redeem_to_cashu), color = Color.White, fontSize = 16.sp)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Button(
|
||||
onClick = {
|
||||
// Copying the token to clipboard
|
||||
clipboardManager.setText(AnnotatedString(token.token))
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
CopyIcon(Size20Modifier, Color.White)
|
||||
Spacer(DoubleHorzSpacer)
|
||||
Text(stringResource(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CashuPreviewNew(
|
||||
token: CashuToken,
|
||||
melt: (CashuToken, Context, (String, String) -> Unit) -> Unit,
|
||||
toast: (String, String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.cashu),
|
||||
null,
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "$it ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp)
|
||||
text = stringResource(R.string.cashu),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(start = 5.dp, bottom = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
Text(
|
||||
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 20.sp
|
||||
)
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Button(
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
var isRedeeming by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp).padding(horizontal = 2.dp),
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
if (lud16 != null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
CashuProcessor().melt(
|
||||
token,
|
||||
lud16,
|
||||
onSuccess = { title, message ->
|
||||
accountViewModel.toast(title, message)
|
||||
},
|
||||
onError = { title, message ->
|
||||
accountViewModel.toast(title, message)
|
||||
},
|
||||
context
|
||||
)
|
||||
}
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
context.getString(R.string.no_lightning_address_set),
|
||||
context.getString(R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, accountViewModel.account.userProfile().toBestDisplayName())
|
||||
)
|
||||
isRedeeming = true
|
||||
melt(token, context) { title, message ->
|
||||
toast(title, message)
|
||||
isRedeeming = false
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
shape = SmallishBorder
|
||||
) {
|
||||
if (isRedeeming) {
|
||||
LoadingAnimation()
|
||||
} else {
|
||||
ZapIcon(Size20dp, tint = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
Spacer(StdHorzSpacer)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.cashu_redeem),
|
||||
color = Color.White,
|
||||
fontSize = 18.sp
|
||||
"Redeem",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp).padding(horizontal = 1.dp),
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
if (useWebService) {
|
||||
// In case we want to use the cashu.me webservice
|
||||
val url = "https://redeem.cashu.me?token=$token&lightning=$lud16&autopay=false"
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}"))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
startActivity(context, intent, null)
|
||||
} else {
|
||||
// Copying the token to clipboard for now
|
||||
clipboardManager.setText(AnnotatedString(token.token))
|
||||
} catch (e: Exception) {
|
||||
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
shape = SmallishBorder,
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("⎘", color = Color.White, fontSize = 18.sp)
|
||||
OpenInNewIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
// Copying the token to clipboard
|
||||
clipboardManager.setText(AnnotatedString(token.token))
|
||||
},
|
||||
shape = SmallishBorder,
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
CopyIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +101,14 @@ fun InvoicePreview(lnInvoice: String, amount: String?) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -93,7 +93,7 @@ class MarkdownParser {
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
val removedParamsFromUrl = removeQueryParams(word)
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
returnContent += " "
|
||||
} else {
|
||||
|
||||
@@ -69,11 +69,16 @@ class MediaCompressor {
|
||||
}
|
||||
}
|
||||
)
|
||||
} else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif")) {
|
||||
val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) {
|
||||
default(width = 640, format = Bitmap.CompressFormat.JPEG)
|
||||
} else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif") && !contentType.contains("svg")) {
|
||||
try {
|
||||
val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) {
|
||||
default(width = 640, format = Bitmap.CompressFormat.JPEG)
|
||||
}
|
||||
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onReady(uri, contentType, null)
|
||||
}
|
||||
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
|
||||
} else {
|
||||
onReady(uri, contentType, null)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,16 @@ val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "
|
||||
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
|
||||
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
fun removeQueryParams(fullUrl: String): String {
|
||||
return if (fullUrl.contains("?")) {
|
||||
fullUrl.split("?")[0].lowercase()
|
||||
} else if (fullUrl.contains("#")) {
|
||||
fullUrl.split("#")[0].lowercase()
|
||||
} else {
|
||||
fullUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun isValidURL(url: String?): Boolean {
|
||||
return try {
|
||||
URL(url).toURI()
|
||||
|
||||
@@ -25,6 +25,7 @@ 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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -43,6 +44,30 @@ fun TextSpinner(
|
||||
options: ImmutableList<TitleExplainer>,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextSpinner(
|
||||
placeholder,
|
||||
options,
|
||||
onSelect,
|
||||
modifier
|
||||
) { currentOption, modifier ->
|
||||
OutlinedTextField(
|
||||
value = currentOption,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { label?.let { Text(it) } },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextSpinner(
|
||||
placeholder: String,
|
||||
options: ImmutableList<TitleExplainer>,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
@@ -50,16 +75,16 @@ fun TextSpinner(
|
||||
var currentText by remember { mutableStateOf(placeholder) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentText,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { label?.let { Text(it) } },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
mainElement(
|
||||
currentText,
|
||||
remember {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
}
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -17,10 +17,12 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -44,6 +46,8 @@ import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
@@ -67,6 +71,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.linc.audiowaveform.infiniteLinearGradient
|
||||
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
|
||||
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.MuteIcon
|
||||
@@ -76,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size0dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -160,6 +166,8 @@ fun VideoView(
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
@@ -168,22 +176,81 @@ fun VideoView(
|
||||
) {
|
||||
val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) }
|
||||
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
alwaysShowVideo = alwaysShowVideo,
|
||||
accountViewModel = accountViewModel,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
val automaticallyStartPlayback = remember {
|
||||
mutableStateOf<Boolean>(
|
||||
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
|
||||
)
|
||||
}
|
||||
|
||||
if (blurhash == null) {
|
||||
val ratio = aspectRatio(dimensions)
|
||||
val modifier = if (ratio != null && roundedCorner) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
|
||||
} else {
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val ratio = aspectRatio(dimensions)
|
||||
val modifier = if (ratio != null && roundedCorner) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
DisplayBlurHash(blurhash, null, ContentScale.Crop, MaterialTheme.colorScheme.imageModifier)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
onClick = { automaticallyStartPlayback.value = true }
|
||||
) {
|
||||
DownloadForOfflineIcon(Size75dp, Color.White)
|
||||
}
|
||||
} else {
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -198,68 +265,61 @@ fun VideoViewInner(
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
alwaysShowVideo: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
val automaticallyStartPlayback = remember {
|
||||
mutableStateOf<Boolean>(
|
||||
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
|
||||
)
|
||||
}
|
||||
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
|
||||
} else {
|
||||
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
|
||||
val mediaItem = remember(videoUri) {
|
||||
mutableStateOf(
|
||||
MediaItem.Builder()
|
||||
.setMediaId(videoUri)
|
||||
.setUri(videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(authorName?.ifBlank { null })
|
||||
.setTitle(title?.ifBlank { null } ?: videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (artworkUri != null) {
|
||||
Uri.parse(artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
|
||||
val mediaItem = remember(videoUri) {
|
||||
mutableStateOf(
|
||||
MediaItem.Builder()
|
||||
.setMediaId(videoUri)
|
||||
.setUri(videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(authorName?.ifBlank { null })
|
||||
.setTitle(title?.ifBlank { null } ?: videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (artworkUri != null) {
|
||||
Uri.parse(artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
GetVideoController(
|
||||
mediaItem = mediaItem,
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
nostrUriCallback = nostrUriCallback
|
||||
) { controller, keepPlaying ->
|
||||
RenderVideoPlayer(
|
||||
controller = controller,
|
||||
thumbData = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
keepPlaying = keepPlaying,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
activeOnScreen = activeOnScreen,
|
||||
modifier = modifier,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
GetVideoController(
|
||||
mediaItem = mediaItem,
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
nostrUriCallback = nostrUriCallback
|
||||
) { controller, keepPlaying ->
|
||||
RenderVideoPlayer(
|
||||
controller = controller,
|
||||
thumbData = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
keepPlaying = keepPlaying,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
activeOnScreen = activeOnScreen,
|
||||
modifier = modifier,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -534,6 +594,8 @@ private fun RenderVideoPlayer(
|
||||
controller: MediaController,
|
||||
thumbData: VideoThumb?,
|
||||
roundedCorner: Boolean,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
@@ -586,6 +648,8 @@ private fun RenderVideoPlayer(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setBackgroundColor(Color.Transparent.toArgb())
|
||||
setShutterBackgroundColor(Color.Transparent.toArgb())
|
||||
controllerAutoShow = false
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
@@ -609,6 +673,19 @@ private fun RenderVideoPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
val ratio = remember {
|
||||
aspectRatio(dimensions)
|
||||
}
|
||||
|
||||
if (ratio != null) {
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
null,
|
||||
ContentScale.Crop,
|
||||
myModifier.aspectRatio(ratio)
|
||||
)
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = myModifier,
|
||||
factory = factory
|
||||
|
||||
@@ -22,12 +22,15 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
@@ -56,6 +59,7 @@ 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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
@@ -77,6 +81,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
@@ -99,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
@@ -145,7 +151,8 @@ class ZoomableUrlVideo(
|
||||
dim: String? = null,
|
||||
uri: String? = null,
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null
|
||||
val authorName: String? = null,
|
||||
val blurhash: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
@Immutable
|
||||
@@ -182,7 +189,7 @@ class ZoomableLocalVideo(
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
val removedParamsFromUrl = removeQueryParams(fullUrl)
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
|
||||
@@ -246,6 +253,8 @@ fun ZoomableContentView(
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
dimensions = content.dim,
|
||||
blurhash = content.blurhash,
|
||||
roundedCorner = roundedCorner,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = { dialogOpen = true },
|
||||
@@ -481,13 +490,38 @@ private fun AddedImageFeatures(
|
||||
verifiedModifier: Modifier,
|
||||
showImage: MutableState<Boolean>
|
||||
) {
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
if (!showImage.value) {
|
||||
ImageUrlWithDownloadButton(content.uri, showImage)
|
||||
if (content.blurhash != null && ratio != null) {
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
myModifier.aspectRatio(ratio).clickable {
|
||||
showImage.value = true
|
||||
}
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
onClick = { showImage.value = true }
|
||||
) {
|
||||
DownloadForOfflineIcon(Size75dp, Color.White)
|
||||
}
|
||||
} else {
|
||||
ImageUrlWithDownloadButton(content.uri, showImage)
|
||||
}
|
||||
} else {
|
||||
when (painter.value) {
|
||||
null, is AsyncImagePainter.State.Loading -> {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
if (ratio != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, ContentScale.Crop, myModifier.aspectRatio(ratio))
|
||||
} else {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
}
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
@@ -521,8 +555,29 @@ private fun AddedImageFeatures(
|
||||
verifiedModifier: Modifier,
|
||||
showImage: MutableState<Boolean>
|
||||
) {
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
if (!showImage.value) {
|
||||
ImageUrlWithDownloadButton(content.url, showImage)
|
||||
if (content.blurhash != null && ratio != null) {
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
myModifier.aspectRatio(ratio).clickable {
|
||||
showImage.value = true
|
||||
}
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
onClick = { showImage.value = true }
|
||||
) {
|
||||
DownloadForOfflineIcon(Size75dp, Color.White)
|
||||
}
|
||||
} else {
|
||||
ImageUrlWithDownloadButton(content.url, showImage)
|
||||
}
|
||||
} else {
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
@@ -531,7 +586,11 @@ private fun AddedImageFeatures(
|
||||
when (painter.value) {
|
||||
null, is AsyncImagePainter.State.Loading -> {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
if (ratio != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, ContentScale.Crop, myModifier.aspectRatio(ratio))
|
||||
} else {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
}
|
||||
} else {
|
||||
FlowRow(Modifier.fillMaxWidth()) {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
@@ -569,8 +628,9 @@ private fun AddedImageFeatures(
|
||||
}
|
||||
}
|
||||
|
||||
private fun aspectRatio(dim: String?): Float? {
|
||||
fun aspectRatio(dim: String?): Float? {
|
||||
if (dim == null) return null
|
||||
if (dim == "0x0") return null
|
||||
|
||||
val parts = dim.split("x")
|
||||
if (parts.size != 2) return null
|
||||
@@ -578,7 +638,12 @@ private fun aspectRatio(dim: String?): Float? {
|
||||
return try {
|
||||
val width = parts[0].toFloat()
|
||||
val height = parts[1].toFloat()
|
||||
width / height
|
||||
|
||||
if (width < 0.1 || height < 0.1) {
|
||||
null
|
||||
} else {
|
||||
width / height
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -664,7 +729,7 @@ private fun InlineLoadingIcon() =
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayBlurHash(
|
||||
fun DisplayBlurHash(
|
||||
blurhash: String?,
|
||||
description: String?,
|
||||
contentScale: ContentScale,
|
||||
@@ -694,7 +759,6 @@ fun ZoomableImageDialog(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
println("This Log only exists to force orientation listener $orientation")
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -704,6 +768,8 @@ fun ZoomableImageDialog(
|
||||
)
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val insets = ViewCompat.getRootWindowInsets(view)
|
||||
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
println("This Log only exists to force orientation listener $orientation")
|
||||
|
||||
@@ -810,6 +876,7 @@ private fun DialogContent(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.statusBarsPadding().systemBarsPadding()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -947,6 +1014,10 @@ private fun RenderImageOrVideo(
|
||||
onToggleControllerVisibility: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val automaticallyStartPlayback = remember {
|
||||
mutableStateOf<Boolean>(true)
|
||||
}
|
||||
|
||||
if (content is ZoomableUrlImage) {
|
||||
val mainModifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -968,16 +1039,15 @@ private fun RenderImageOrVideo(
|
||||
)
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(
|
||||
VideoViewInner(
|
||||
videoUri = content.url,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
alwaysShowVideo = true
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged
|
||||
)
|
||||
}
|
||||
} else if (content is ZoomableLocalImage) {
|
||||
@@ -1002,16 +1072,15 @@ private fun RenderImageOrVideo(
|
||||
} else if (content is ZoomableLocalVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
content.localFile?.let {
|
||||
VideoView(
|
||||
VideoViewInner(
|
||||
videoUri = it.toUri().toString(),
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
alwaysShowVideo = true
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverMarketplaceFeedFilter(
|
||||
val account: Account
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + followList()
|
||||
}
|
||||
|
||||
open fun followList(): String {
|
||||
return account.defaultDiscoveryFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val classifieds = LocalCache.addressables
|
||||
.filter { it.value.event is ClassifiedsEvent }
|
||||
.map { it.value }
|
||||
|
||||
val notes = innerApplyFilter(classifieds)
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
val activities = collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.event is ClassifiedsEvent &&
|
||||
it.event?.hasTagWithContent("image") == true &&
|
||||
it.event?.hasTagWithContent("price") == true &&
|
||||
it.event?.hasTagWithContent("title") == true
|
||||
}
|
||||
.filter {
|
||||
isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true || it.event?.isTaggedGeoHashes(followingGeohashSet) == true
|
||||
}
|
||||
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
|
||||
.filter { (it.createdAt() ?: 0) <= now }
|
||||
.toSet()
|
||||
|
||||
return activities
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -53,23 +54,30 @@ enum class Keyboard {
|
||||
Opened, Closed
|
||||
}
|
||||
|
||||
fun isKeyboardOpen(view: View): Keyboard {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
|
||||
return if (keypadHeight > screenHeight * 0.15) {
|
||||
Keyboard.Opened
|
||||
} else {
|
||||
Keyboard.Closed
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun keyboardAsState(): State<Keyboard> {
|
||||
val keyboardState = remember { mutableStateOf(Keyboard.Closed) }
|
||||
val view = LocalView.current
|
||||
|
||||
val keyboardState = remember(view) {
|
||||
mutableStateOf(isKeyboardOpen(view))
|
||||
}
|
||||
|
||||
DisposableEffect(view) {
|
||||
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
|
||||
val newKeyboardValue = if (keypadHeight > screenHeight * 0.15) {
|
||||
Keyboard.Opened
|
||||
} else {
|
||||
Keyboard.Closed
|
||||
}
|
||||
val newKeyboardValue = isKeyboardOpen(view)
|
||||
|
||||
if (newKeyboardValue != keyboardState.value) {
|
||||
keyboardState.value = newKeyboardValue
|
||||
@@ -89,8 +97,8 @@ fun keyboardAsState(): State<Keyboard> {
|
||||
fun IfKeyboardClosed(
|
||||
inner: @Composable () -> Unit
|
||||
) {
|
||||
val isKeyboardOpen by keyboardAsState()
|
||||
if (isKeyboardOpen == Keyboard.Closed) {
|
||||
val isKeyboardState by keyboardAsState()
|
||||
if (isKeyboardState == Keyboard.Closed) {
|
||||
inner()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||
@@ -61,6 +62,7 @@ fun AppNavigation(
|
||||
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
|
||||
newFeedViewModel: NostrChatroomListNewFeedViewModel,
|
||||
videoFeedViewModel: NostrVideoFeedViewModel,
|
||||
discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
|
||||
@@ -137,6 +139,7 @@ fun AppNavigation(
|
||||
Route.Discover.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
DiscoverScreen(
|
||||
discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
|
||||
|
||||
@@ -809,7 +809,7 @@ fun RenderOption(option: Name) {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val name by option.note.live().metadata.map {
|
||||
"/n/" + (it.note as? AddressableNote)?.dTag()
|
||||
"/n/" + ((it.note as? AddressableNote)?.dTag() ?: "")
|
||||
}.observeAsState()
|
||||
|
||||
Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface)
|
||||
|
||||
@@ -74,12 +74,20 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font18SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.IconRowModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.IconRowTextModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.bannerModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.drawerSpacing
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -90,6 +98,15 @@ fun DrawerContent(
|
||||
openSheet: () -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val onClickUser = {
|
||||
nav("User/${accountViewModel.userProfile().pubkeyHex}")
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
@@ -100,27 +117,31 @@ fun DrawerContent(
|
||||
) {
|
||||
Column() {
|
||||
ProfileContent(
|
||||
accountViewModel.account.userProfile(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 25.dp)
|
||||
.padding(top = 70.dp),
|
||||
drawerState,
|
||||
baseAccountUser = accountViewModel.account.userProfile(),
|
||||
modifier = profileContentHeaderModifier,
|
||||
accountViewModel,
|
||||
nav
|
||||
onClickUser
|
||||
)
|
||||
|
||||
Column(drawerSpacing) {
|
||||
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel)
|
||||
}
|
||||
|
||||
FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser)
|
||||
|
||||
Divider(
|
||||
thickness = DividerThickness,
|
||||
modifier = Modifier.padding(top = 20.dp)
|
||||
)
|
||||
|
||||
ListContent(
|
||||
nav,
|
||||
drawerState,
|
||||
openSheet,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
accountViewModel
|
||||
drawerState,
|
||||
openSheet,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
|
||||
BottomContent(
|
||||
@@ -137,45 +158,48 @@ fun DrawerContent(
|
||||
fun ProfileContent(
|
||||
baseAccountUser: User,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerState: DrawerState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
onClickUser: () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState()
|
||||
|
||||
val accountUserState by baseAccountUser.live().metadata.observeAsState()
|
||||
|
||||
val profilePubHex = remember(accountUserState) { accountUserState?.user?.pubkeyHex } ?: return
|
||||
|
||||
val profileBanner = remember(accountUserState) { accountUserState?.user?.info?.banner?.ifBlank { null } }
|
||||
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
|
||||
// val bestUserName = remember(accountUserState) { accountUserState?.user?.bestUsername() }
|
||||
val bestDisplayName = remember(accountUserState) { accountUserState?.user?.toBestDisplayName() }
|
||||
val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
|
||||
val route = remember(accountUserState) { "User/${accountUserState?.user?.pubkeyHex}" }
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
ProfileContentTemplate(
|
||||
profilePubHex = baseAccountUser.pubkeyHex,
|
||||
profileBanner = userInfo?.banner,
|
||||
profilePicture = userInfo?.profilePicture(),
|
||||
bestDisplayName = userInfo?.bestDisplayName(),
|
||||
tags = userInfo?.tags,
|
||||
modifier = modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = onClickUser
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileContentTemplate(
|
||||
profilePubHex: HexKey,
|
||||
profileBanner: String?,
|
||||
profilePicture: String?,
|
||||
bestDisplayName: String?,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box {
|
||||
if (profileBanner != null) {
|
||||
AsyncImage(
|
||||
model = profileBanner,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
modifier = bannerModifier
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.profile_banner),
|
||||
contentDescription = stringResource(R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
modifier = bannerModifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,13 +214,8 @@ fun ProfileContent(
|
||||
.clip(shape = CircleShape)
|
||||
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.clickable(onClick = {
|
||||
nav(route)
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}),
|
||||
loadProfilePicture = automaticallyShowProfilePicture
|
||||
.clickable(onClick = onClick),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures.value
|
||||
)
|
||||
|
||||
if (bestDisplayName != null) {
|
||||
@@ -205,139 +224,98 @@ fun ProfileContent(
|
||||
tags = tags,
|
||||
modifier = Modifier
|
||||
.padding(top = 7.dp)
|
||||
.clickable(onClick = {
|
||||
nav(route)
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}),
|
||||
.clickable(onClick = onClick),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Column(Modifier.padding(top = Size10dp)) {
|
||||
EditStatusBoxes(baseAccountUser, accountViewModel)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = Size10dp)
|
||||
.clickable(onClick = {
|
||||
nav(route)
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
})
|
||||
) {
|
||||
FollowingAndFollowerCounts(baseAccountUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditStatusBoxes(baseAccountUser: User, accountViewModel: AccountViewModel) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LoadStatuses(user = baseAccountUser, accountViewModel) { statuses ->
|
||||
if (statuses.isEmpty()) {
|
||||
val currentStatus = remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val hasChanged by remember {
|
||||
derivedStateOf {
|
||||
currentStatus.value != ""
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentStatus.value,
|
||||
onValueChange = { currentStatus.value = it },
|
||||
label = { Text(text = stringResource(R.string.status_update)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.status_update),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Send,
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (hasChanged) {
|
||||
SendButton() {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
StatusEditBar(accountViewModel = accountViewModel)
|
||||
} else {
|
||||
statuses.forEach {
|
||||
val originalStatus by it.live().content.observeAsState("")
|
||||
val originalStatus by it.live().content.observeAsState()
|
||||
|
||||
val thisStatus = remember {
|
||||
mutableStateOf(originalStatus)
|
||||
}
|
||||
val hasChanged by remember {
|
||||
derivedStateOf {
|
||||
thisStatus.value != originalStatus
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = thisStatus.value,
|
||||
onValueChange = { thisStatus.value = it },
|
||||
label = { Text(text = stringResource(R.string.status_update)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.status_update),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Send,
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
accountViewModel.updateStatus(it, thisStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (hasChanged) {
|
||||
SendButton() {
|
||||
accountViewModel.updateStatus(it, thisStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
} else {
|
||||
UserStatusDeleteButton() {
|
||||
accountViewModel.deleteStatus(it)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
StatusEditBar(originalStatus, it.address, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusEditBar(
|
||||
savedStatus: String? = null,
|
||||
tag: ATag? = null,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val currentStatus = remember {
|
||||
mutableStateOf(savedStatus ?: "")
|
||||
}
|
||||
val hasChanged = remember {
|
||||
derivedStateOf {
|
||||
currentStatus.value != (savedStatus ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentStatus.value,
|
||||
onValueChange = { currentStatus.value = it },
|
||||
label = { Text(text = stringResource(R.string.status_update)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.status_update),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Send,
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
if (tag == null) {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
} else {
|
||||
accountViewModel.updateStatus(tag, currentStatus.value)
|
||||
}
|
||||
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (hasChanged.value) {
|
||||
SendButton {
|
||||
if (tag == null) {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
} else {
|
||||
accountViewModel.updateStatus(tag, currentStatus.value)
|
||||
}
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
} else {
|
||||
if (tag != null) {
|
||||
UserStatusDeleteButton {
|
||||
accountViewModel.deleteStatus(tag)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SendButton(onClick: () -> Unit) {
|
||||
IconButton(
|
||||
@@ -369,7 +347,7 @@ fun UserStatusDeleteButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowingAndFollowerCounts(baseAccountUser: User) {
|
||||
private fun FollowingAndFollowerCounts(baseAccountUser: User, onClick: () -> Unit) {
|
||||
var followingCount by remember { mutableStateOf("--") }
|
||||
var followerCount by remember { mutableStateOf("--") }
|
||||
|
||||
@@ -385,21 +363,25 @@ private fun FollowingAndFollowerCounts(baseAccountUser: User) {
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = followingCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Row(
|
||||
modifier = drawerSpacing.clickable(onClick = onClick)
|
||||
) {
|
||||
Text(
|
||||
text = followingCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.following))
|
||||
Text(stringResource(R.string.following))
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Text(
|
||||
text = followerCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = followerCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.followers))
|
||||
Text(stringResource(R.string.followers))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -426,20 +408,18 @@ fun WatchFollower(baseAccountUser: User, onReady: (String) -> Unit) {
|
||||
|
||||
@Composable
|
||||
fun ListContent(
|
||||
nav: (String) -> Unit,
|
||||
modifier: Modifier,
|
||||
drawerState: DrawerState,
|
||||
openSheet: () -> Unit,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val route = remember(accountViewModel) {
|
||||
"User/${accountViewModel.userProfile().pubkeyHex}"
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var wantsToEditRelays by remember { mutableStateOf(false) }
|
||||
|
||||
var backupDialogOpen by remember { mutableStateOf(false) }
|
||||
var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) }
|
||||
@@ -504,9 +484,8 @@ fun ListContent(
|
||||
)
|
||||
}
|
||||
|
||||
val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short)
|
||||
IconRow(
|
||||
title = textTorProxy,
|
||||
title = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short),
|
||||
icon = R.drawable.ic_tor,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
onLongClick = {
|
||||
@@ -672,21 +651,19 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongCl
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 15.dp, horizontal = 25.dp),
|
||||
modifier = IconRowModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(icon),
|
||||
null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
modifier = Size22Modifier,
|
||||
tint = tint
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
modifier = IconRowTextModifier,
|
||||
text = title,
|
||||
fontSize = 18.sp
|
||||
fontSize = Font18SP
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -15,8 +16,10 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
@@ -39,6 +42,7 @@ import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
@@ -58,23 +62,28 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
|
||||
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.HalfPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
|
||||
import com.vitorpamplona.quartz.events.Participant
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -95,7 +104,7 @@ fun ChannelCardCompose(
|
||||
) {
|
||||
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
|
||||
|
||||
Crossfade(targetState = hasEvent) {
|
||||
Crossfade(targetState = hasEvent, label = "ChannelCardCompose") {
|
||||
if (it) {
|
||||
if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) {
|
||||
CheckHiddenChannelCardCompose(
|
||||
@@ -155,7 +164,7 @@ fun CheckHiddenChannelCardCompose(
|
||||
note.isHiddenFor(it)
|
||||
}.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note))
|
||||
|
||||
Crossfade(targetState = isHidden) {
|
||||
Crossfade(targetState = isHidden, label = "CheckHiddenChannelCardCompose") {
|
||||
if (!it) {
|
||||
LoadedChannelCardCompose(
|
||||
note,
|
||||
@@ -195,7 +204,7 @@ fun LoadedChannelCardCompose(
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = state) {
|
||||
Crossfade(targetState = state, label = "CheckHiddenChannelCardCompose") {
|
||||
RenderChannelCardReportState(
|
||||
it,
|
||||
note,
|
||||
@@ -220,7 +229,7 @@ fun RenderChannelCardReportState(
|
||||
) {
|
||||
var showReportedNote by remember { mutableStateOf(false) }
|
||||
|
||||
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
|
||||
Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "CheckHiddenChannelCardCompose") { showHiddenNote ->
|
||||
if (showHiddenNote) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
@@ -334,6 +343,28 @@ fun InnerChannelCardWithReactions(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
when (remember { baseNote.event }) {
|
||||
is LiveActivitiesEvent -> {
|
||||
InnerCardRow(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is CommunityDefinitionEvent -> {
|
||||
InnerCardRow(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is ChannelCreateEvent -> {
|
||||
InnerCardRow(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is ClassifiedsEvent -> {
|
||||
InnerCardBox(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InnerCardRow(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column(StdPadding) {
|
||||
SensitivityWarning(
|
||||
@@ -353,6 +384,22 @@ fun InnerChannelCardWithReactions(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InnerCardBox(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column(HalfPadding) {
|
||||
SensitivityWarning(
|
||||
note = baseNote,
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
RenderClassifiedsThumb(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderNoteRow(
|
||||
baseNote: Note,
|
||||
@@ -372,6 +419,116 @@ private fun RenderNoteRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ClassifiedsThumb(
|
||||
val image: String?,
|
||||
val title: String?,
|
||||
val price: Price?
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RenderClassifiedsThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteEvent = baseNote.event as? ClassifiedsEvent ?: return
|
||||
|
||||
val card by baseNote.live().metadata.map {
|
||||
val noteEvent = it.note.event as? ClassifiedsEvent
|
||||
|
||||
ClassifiedsThumb(
|
||||
image = noteEvent?.image(),
|
||||
title = noteEvent?.title(),
|
||||
price = noteEvent?.price()
|
||||
)
|
||||
}.distinctUntilChanged().observeAsState(
|
||||
ClassifiedsThumb(
|
||||
image = noteEvent.image(),
|
||||
title = noteEvent.title(),
|
||||
price = noteEvent.price()
|
||||
)
|
||||
)
|
||||
|
||||
RenderClassifiedsThumb(card, baseNote.author)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderClassifiedsThumbPreview() {
|
||||
Surface(Modifier.size(200.dp)) {
|
||||
RenderClassifiedsThumb(
|
||||
card = ClassifiedsThumb(
|
||||
image = null,
|
||||
title = "Like New",
|
||||
price = Price("800000", "SATS", null)
|
||||
),
|
||||
author = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderClassifiedsThumb(card: ClassifiedsThumb, author: User?) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
contentAlignment = BottomStart
|
||||
) {
|
||||
card.image?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} ?: run {
|
||||
author?.let {
|
||||
DisplayAuthorBanner(it)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Black.copy(0.6f))
|
||||
.padding(Size5dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
card.title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.White,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
card.price?.let {
|
||||
val priceTag = remember(card) {
|
||||
val newAmount = it.amount.toBigDecimalOrNull()?.let {
|
||||
showAmountAxis(it)
|
||||
} ?: it.amount
|
||||
|
||||
if (it.frequency != null && it.currency != null) {
|
||||
"$newAmount ${it.currency}/${it.frequency}"
|
||||
} else if (it.currency != null) {
|
||||
"$newAmount ${it.currency}"
|
||||
} else {
|
||||
newAmount
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = priceTag,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LiveActivityCard(
|
||||
val name: String,
|
||||
|
||||
@@ -6,12 +6,14 @@ import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.DownloadForOffline
|
||||
import androidx.compose.material.icons.filled.Downloading
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.filled.VolumeOff
|
||||
@@ -76,12 +78,12 @@ fun MessageIcon(modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadForOfflineIcon(iconSize: Dp) {
|
||||
fun DownloadForOfflineIcon(iconSize: Dp, tint: Color = MaterialTheme.colorScheme.primary) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DownloadForOffline,
|
||||
null,
|
||||
modifier = remember(iconSize) { Modifier.size(iconSize) },
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,6 +177,36 @@ fun ZapIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CashuIcon(modifier: Modifier) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.cashu),
|
||||
"Cashu",
|
||||
tint = Color.Unspecified,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CopyIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
stringResource(id = R.string.copy_to_clipboard),
|
||||
tint = tint,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenInNewIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.OpenInNew,
|
||||
stringResource(id = R.string.copy_to_clipboard),
|
||||
tint = tint,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpandLessIcon(modifier: Modifier) {
|
||||
Icon(
|
||||
|
||||
@@ -134,7 +134,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
|
||||
}
|
||||
Log.d("Rendering Metrics", "All Galeries: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed - ")
|
||||
|
||||
Row(remember { Modifier.fillMaxWidth() }) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Spacer(modifier = WidthAuthorPictureModifierWithPadding)
|
||||
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
@@ -176,7 +176,9 @@ private fun Galeries(
|
||||
|
||||
if (hasZapEvents) {
|
||||
var zapEvents by remember(multiSetCard.zapEvents) {
|
||||
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
|
||||
mutableStateOf(
|
||||
accountViewModel.cachedDecryptAmountMessageInGroup(multiSetCard.zapEvents)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
|
||||
@@ -76,6 +76,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.get
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import coil.compose.AsyncImage
|
||||
@@ -112,6 +113,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.removeQueryParams
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
@@ -434,8 +436,9 @@ fun WatchForReports(
|
||||
) {
|
||||
val userFollowsState by accountViewModel.userFollows.observeAsState()
|
||||
val noteReportsState by note.live().reports.observeAsState()
|
||||
val userBlocks by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState) {
|
||||
LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState, userBlocks) {
|
||||
accountViewModel.isNoteAcceptable(note, onChange)
|
||||
}
|
||||
}
|
||||
@@ -3452,7 +3455,7 @@ fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: Acco
|
||||
val hash = event.hash()
|
||||
val dimensions = event.dimensions()
|
||||
val description = event.alt() ?: event.content
|
||||
val isImage = imageExtensions.any { fullUrl.split("?")[0].lowercase().endsWith(it) }
|
||||
val isImage = imageExtensions.any { removeQueryParams(fullUrl).lowercase().endsWith(it) }
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
mutableStateOf<ZoomableContent>(
|
||||
|
||||
@@ -453,7 +453,9 @@ private fun WatchZapAndRenderGallery(
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
var zapEvents by remember(zapsState) {
|
||||
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
|
||||
mutableStateOf(
|
||||
accountViewModel.cachedDecryptAmountMessageInGroup(baseNote)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
|
||||
@@ -57,7 +57,7 @@ class AccountStateViewModel() : ViewModel() {
|
||||
_accountContent.update { AccountState.LoggedOff }
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
serviceManager?.pauseForGood()
|
||||
serviceManager?.pauseForGoodAndClearAccount()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,10 @@ class AccountStateViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
// Prepares livedata objects on the main user.
|
||||
account.userProfile().live()
|
||||
}
|
||||
serviceManager?.restartIfDifferentAccount(account)
|
||||
}
|
||||
|
||||
|
||||
@@ -160,8 +160,7 @@ private fun FeedLoaded(
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
|
||||
val defaultModifier = remember {
|
||||
Modifier
|
||||
.fillMaxWidth().animateItemPlacement()
|
||||
Modifier.fillMaxWidth().animateItemPlacement()
|
||||
}
|
||||
|
||||
Row(defaultModifier) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Button
|
||||
@@ -64,7 +66,9 @@ fun RefresheableView(
|
||||
|
||||
val modifier = remember {
|
||||
if (enablePullRefresh) {
|
||||
Modifier.fillMaxSize().pullRefresh(pullRefreshState)
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pullRefresh(pullRefreshState)
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
}
|
||||
@@ -115,6 +119,23 @@ fun SaveableFeedState(
|
||||
content(listState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SaveableGridFeedState(
|
||||
viewModel: FeedViewModel,
|
||||
scrollStateKey: String? = null,
|
||||
content: @Composable (LazyGridState) -> Unit
|
||||
) {
|
||||
val gridState = if (scrollStateKey != null) {
|
||||
rememberForeverLazyGridState(scrollStateKey)
|
||||
} else {
|
||||
rememberLazyGridState()
|
||||
}
|
||||
|
||||
WatchScrollToTop(viewModel, gridState)
|
||||
|
||||
content(gridState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderFeed(
|
||||
viewModel: FeedViewModel,
|
||||
@@ -174,6 +195,21 @@ private fun WatchScrollToTop(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchScrollToTop(
|
||||
viewModel: FeedViewModel,
|
||||
listState: LazyGridState
|
||||
) {
|
||||
val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(scrollToTop) {
|
||||
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
|
||||
listState.scrollToItem(index = 0)
|
||||
viewModel.sentToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun FeedLoaded(
|
||||
@@ -190,7 +226,9 @@ private fun FeedLoaded(
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
val defaultModifier = remember {
|
||||
Modifier.fillMaxWidth().animateItemPlacement()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItemPlacement()
|
||||
}
|
||||
|
||||
Row(defaultModifier) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.dal.CommunityFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DiscoverMarketplaceFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
|
||||
@@ -72,6 +73,16 @@ class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFil
|
||||
}
|
||||
}
|
||||
|
||||
class NostrDiscoverMarketplaceFeedViewModel(val account: Account) : FeedViewModel(
|
||||
DiscoverMarketplaceFeedFilter(account)
|
||||
) {
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrDiscoverMarketplaceFeedViewModel : ViewModel> create(modelClass: Class<NostrDiscoverMarketplaceFeedViewModel>): NostrDiscoverMarketplaceFeedViewModel {
|
||||
return NostrDiscoverMarketplaceFeedViewModel(account) as NostrDiscoverMarketplaceFeedViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NostrDiscoverLiveFeedViewModel(val account: Account) : FeedViewModel(DiscoverLiveFeedFilter(account)) {
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrDiscoverLiveFeedViewModel : ViewModel> create(modelClass: Class<NostrDiscoverLiveFeedViewModel>): NostrDiscoverLiveFeedViewModel {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -22,6 +23,7 @@ object ScrollStateKeys {
|
||||
val HOME_FOLLOWS = Route.Home.base + "Follows"
|
||||
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
|
||||
|
||||
val DISCOVER_MARKETPLACE = Route.Home.base + "Marketplace"
|
||||
val DISCOVER_LIVE = Route.Home.base + "Live"
|
||||
val DISCOVER_COMMUNITY = Route.Home.base + "Communities"
|
||||
val DISCOVER_CHATS = Route.Home.base + "Chats"
|
||||
@@ -32,6 +34,31 @@ object PagerStateKeys {
|
||||
const val DISCOVER_SCREEN = "PagerDiscover"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberForeverLazyGridState(
|
||||
key: String,
|
||||
initialFirstVisibleItemIndex: Int = 0,
|
||||
initialFirstVisibleItemScrollOffset: Int = 0
|
||||
): LazyGridState {
|
||||
val scrollState = rememberSaveable(saver = LazyGridState.Saver) {
|
||||
val savedValue = savedScrollStates[key]
|
||||
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
|
||||
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat()
|
||||
LazyGridState(
|
||||
savedIndex,
|
||||
savedOffset.roundToInt()
|
||||
)
|
||||
}
|
||||
DisposableEffect(scrollState) {
|
||||
onDispose {
|
||||
val lastIndex = scrollState.firstVisibleItemIndex
|
||||
val lastOffset = scrollState.firstVisibleItemScrollOffset
|
||||
savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat())
|
||||
}
|
||||
}
|
||||
return scrollState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberForeverLazyListState(
|
||||
key: String,
|
||||
|
||||
+157
-10
@@ -27,6 +27,8 @@ 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.CashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.CashuToken
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.Nip05Verifier
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
@@ -71,7 +73,10 @@ import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@Immutable
|
||||
@@ -220,29 +225,115 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
onNewState: (ImmutableList<ZapAmountCommentNotification>) -> Unit
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
allOrNothingSigningOperations<CombinedZap, ZapAmountCommentNotification>(
|
||||
remainingTos = zaps,
|
||||
val myList = zaps.toList()
|
||||
|
||||
val initialResults = myList.associate {
|
||||
it.request to ZapAmountCommentNotification(
|
||||
it.request.author,
|
||||
it.request.event?.content()?.ifBlank { null },
|
||||
showAmountAxis((it.response?.event as? LnZapEvent)?.amount)
|
||||
)
|
||||
}.toMutableMap()
|
||||
|
||||
collectSuccessfulSigningOperations<CombinedZap, ZapAmountCommentNotification>(
|
||||
operationsInput = myList,
|
||||
runRequestFor = { next, onReady ->
|
||||
innerDecryptAmountMessage(next.request, next.response, onReady)
|
||||
}
|
||||
) {
|
||||
onNewState(it.toImmutableList())
|
||||
it.forEach { decrypted ->
|
||||
initialResults[decrypted.key.request] = decrypted.value
|
||||
}
|
||||
|
||||
onNewState(initialResults.values.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cachedDecryptAmountMessageInGroup(zapNotes: List<CombinedZap>): ImmutableList<ZapAmountCommentNotification> {
|
||||
return zapNotes.map {
|
||||
val request = it.request.event as? LnZapRequestEvent
|
||||
if (request?.isPrivateZap() == true) {
|
||||
val cachedPrivateRequest = request.cachedPrivateZap()
|
||||
if (cachedPrivateRequest != null) {
|
||||
ZapAmountCommentNotification(
|
||||
LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author,
|
||||
cachedPrivateRequest.content.ifBlank { null },
|
||||
showAmountAxis((it.response.event as? LnZapEvent)?.amount)
|
||||
)
|
||||
} else {
|
||||
ZapAmountCommentNotification(
|
||||
it.request.author,
|
||||
it.request.event?.content()?.ifBlank { null },
|
||||
showAmountAxis((it.response.event as? LnZapEvent)?.amount)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ZapAmountCommentNotification(
|
||||
it.request.author,
|
||||
it.request.event?.content()?.ifBlank { null },
|
||||
showAmountAxis((it.response.event as? LnZapEvent)?.amount)
|
||||
)
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
fun cachedDecryptAmountMessageInGroup(baseNote: Note): ImmutableList<ZapAmountCommentNotification> {
|
||||
val myList = baseNote.zaps.toList()
|
||||
|
||||
return myList.map {
|
||||
val request = it.first.event as? LnZapRequestEvent
|
||||
if (request?.isPrivateZap() == true) {
|
||||
val cachedPrivateRequest = request.cachedPrivateZap()
|
||||
if (cachedPrivateRequest != null) {
|
||||
ZapAmountCommentNotification(
|
||||
LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author,
|
||||
cachedPrivateRequest.content.ifBlank { null },
|
||||
showAmountAxis((it.second?.event as? LnZapEvent)?.amount)
|
||||
)
|
||||
} else {
|
||||
ZapAmountCommentNotification(
|
||||
it.first.author,
|
||||
it.first.event?.content()?.ifBlank { null },
|
||||
showAmountAxis((it.second?.event as? LnZapEvent)?.amount)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ZapAmountCommentNotification(
|
||||
it.first.author,
|
||||
it.first.event?.content()?.ifBlank { null },
|
||||
showAmountAxis((it.second?.event as? LnZapEvent)?.amount)
|
||||
)
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
fun decryptAmountMessageInGroup(
|
||||
baseNote: Note,
|
||||
onNewState: (ImmutableList<ZapAmountCommentNotification>) -> Unit
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
allOrNothingSigningOperations<Pair<Note, Note?>, ZapAmountCommentNotification>(
|
||||
remainingTos = baseNote.zaps.toList(),
|
||||
val myList = baseNote.zaps.toList()
|
||||
|
||||
val initialResults = myList.associate {
|
||||
it.first to ZapAmountCommentNotification(
|
||||
it.first.author,
|
||||
it.first.event?.content()?.ifBlank { null },
|
||||
showAmountAxis((it.second?.event as? LnZapEvent)?.amount)
|
||||
)
|
||||
}.toMutableMap()
|
||||
|
||||
collectSuccessfulSigningOperations<Pair<Note, Note?>, ZapAmountCommentNotification>(
|
||||
operationsInput = myList,
|
||||
runRequestFor = { next, onReady ->
|
||||
innerDecryptAmountMessage(next.first, next.second, onReady)
|
||||
}
|
||||
) {
|
||||
onNewState(it.toImmutableList())
|
||||
it.forEach { decrypted ->
|
||||
initialResults[decrypted.key.first] = decrypted.value
|
||||
}
|
||||
|
||||
onNewState(initialResults.values.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,15 +677,15 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStatus(it: AddressableNote, newStatus: String) {
|
||||
fun updateStatus(it: ATag, newStatus: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.updateStatus(it, newStatus)
|
||||
account.updateStatus(LocalCache.getOrCreateAddressableNote(it), newStatus)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteStatus(it: AddressableNote) {
|
||||
fun deleteStatus(it: ATag) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.deleteStatus(it)
|
||||
account.deleteStatus(LocalCache.getOrCreateAddressableNote(it))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,6 +1049,34 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
account.dismissPaymentRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
fun meltCashu(
|
||||
token: CashuToken,
|
||||
context: Context,
|
||||
onDone: (String, String) -> Unit
|
||||
) {
|
||||
val lud16 = account.userProfile().info?.lud16
|
||||
if (lud16 != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
CashuProcessor().melt(
|
||||
token,
|
||||
lud16,
|
||||
onSuccess = { title, message ->
|
||||
onDone(title, message)
|
||||
},
|
||||
onError = { title, message ->
|
||||
onDone(title, message)
|
||||
},
|
||||
context
|
||||
)
|
||||
}
|
||||
} else {
|
||||
onDone(
|
||||
context.getString(R.string.no_lightning_address_set),
|
||||
context.getString(R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, account.userProfile().toBestDisplayName())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HasNotificationDot(bottomNavigationItems: ImmutableList<Route>) {
|
||||
@@ -999,3 +1118,31 @@ public fun <T, K> allOrNothingSigningOperations(
|
||||
allOrNothingSigningOperations(remainingTos.minus(next), runRequestFor, output, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun <T, K> collectSuccessfulSigningOperations(
|
||||
operationsInput: List<T>,
|
||||
runRequestFor: (T, (K) -> Unit) -> Unit,
|
||||
output: MutableMap<T, K> = mutableMapOf(),
|
||||
onReady: (MutableMap<T, K>) -> Unit
|
||||
) {
|
||||
if (operationsInput.isEmpty()) {
|
||||
onReady(output)
|
||||
return
|
||||
}
|
||||
|
||||
for (input in operationsInput) {
|
||||
// runs in sequence to avoid overcrowding Amber.
|
||||
val result = withTimeoutOrNull(100) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
runRequestFor(input) { result: K ->
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result != null) {
|
||||
output[input] = result
|
||||
}
|
||||
}
|
||||
|
||||
onReady(output)
|
||||
}
|
||||
|
||||
@@ -85,12 +85,12 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.ServerOption
|
||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
@@ -401,24 +401,13 @@ fun EditFieldRow(
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = EditFieldLeadingIconModifier
|
||||
) {
|
||||
val fileServer = if (isPrivate) {
|
||||
// TODO: Make private servers
|
||||
when (accountViewModel.account.defaultFileServer) {
|
||||
ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD
|
||||
ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG
|
||||
ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV
|
||||
ServersAvailable.NOSTRCHECK_ME -> ServersAvailable.NOSTRCHECK_ME
|
||||
ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD
|
||||
ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG
|
||||
ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV
|
||||
ServersAvailable.NOSTRCHECK_ME_NIP_94 -> ServersAvailable.NOSTRCHECK_ME
|
||||
ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD
|
||||
}
|
||||
} else {
|
||||
accountViewModel.account.defaultFileServer
|
||||
}
|
||||
|
||||
channelScreenModel.upload(it, "", false, fileServer, context)
|
||||
channelScreenModel.upload(
|
||||
galleryUri = it,
|
||||
alt = null,
|
||||
sensitiveContent = false,
|
||||
server = ServerOption(accountViewModel.account.defaultFileServer, false),
|
||||
context = context
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
|
||||
@@ -65,12 +65,12 @@ import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.ServerOption
|
||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
@@ -379,26 +379,14 @@ fun PrivateMessageEditFieldRow(
|
||||
.size(30.dp)
|
||||
.padding(start = 2.dp)
|
||||
) {
|
||||
val fileServer = if (isPrivate) {
|
||||
// TODO: Make private servers
|
||||
when (accountViewModel.account.defaultFileServer) {
|
||||
ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD
|
||||
ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG
|
||||
ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV
|
||||
ServersAvailable.NOSTRCHECK_ME -> ServersAvailable.NOSTRCHECK_ME
|
||||
|
||||
ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD
|
||||
ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG
|
||||
ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV
|
||||
ServersAvailable.NOSTRCHECK_ME_NIP_94 -> ServersAvailable.NOSTRCHECK_ME
|
||||
|
||||
ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD
|
||||
}
|
||||
} else {
|
||||
accountViewModel.account.defaultFileServer
|
||||
}
|
||||
|
||||
channelScreenModel.upload(it, "", false, fileServer, context)
|
||||
channelScreenModel.upload(
|
||||
galleryUri = it,
|
||||
alt = null,
|
||||
sensitiveContent = false,
|
||||
isPrivate = isPrivate,
|
||||
server = ServerOption(accountViewModel.account.defaultFileServer, false),
|
||||
context = context
|
||||
)
|
||||
}
|
||||
|
||||
var wantsToActivateNIP24 by remember {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -18,8 +19,8 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -47,14 +48,17 @@ import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
|
||||
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -64,6 +68,7 @@ import kotlinx.coroutines.launch
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DiscoverScreen(
|
||||
discoveryMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
|
||||
@@ -75,6 +80,7 @@ fun DiscoverScreen(
|
||||
val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) {
|
||||
mutableStateOf(
|
||||
listOf(
|
||||
TabItem(R.string.discover_marketplace, discoveryMarketplaceFeedViewModel, Route.Discover.base + "Marketplace", ScrollStateKeys.DISCOVER_MARKETPLACE, ClassifiedsEvent.kind),
|
||||
TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.kind),
|
||||
TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.kind),
|
||||
TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS, ChannelCreateEvent.kind)
|
||||
@@ -85,6 +91,7 @@ fun DiscoverScreen(
|
||||
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size }
|
||||
|
||||
WatchAccountForDiscoveryScreen(
|
||||
discoverMarketplaceFeedViewModel = discoveryMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
|
||||
@@ -122,11 +129,12 @@ private fun DiscoverPages(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
TabRow(
|
||||
ScrollableTabRow(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
modifier = TabRowHeight
|
||||
modifier = TabRowHeight,
|
||||
edgePadding = 8.dp
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
@@ -145,15 +153,28 @@ private fun DiscoverPages(
|
||||
|
||||
HorizontalPager(state = pagerState) { page ->
|
||||
RefresheableView(tabs[page].viewModel, true) {
|
||||
SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
||||
RenderDiscoverFeed(
|
||||
viewModel = tabs[page].viewModel,
|
||||
routeForLastRead = tabs[page].routeForLastRead,
|
||||
forceEventKind = tabs[page].forceEventKind,
|
||||
listState = listState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
if (tabs[page].viewModel is NostrDiscoverMarketplaceFeedViewModel) {
|
||||
SaveableGridFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
||||
RenderDiscoverFeed(
|
||||
viewModel = tabs[page].viewModel,
|
||||
routeForLastRead = tabs[page].routeForLastRead,
|
||||
forceEventKind = tabs[page].forceEventKind,
|
||||
listState = listState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
||||
RenderDiscoverFeed(
|
||||
viewModel = tabs[page].viewModel,
|
||||
routeForLastRead = tabs[page].routeForLastRead,
|
||||
forceEventKind = tabs[page].forceEventKind,
|
||||
listState = listState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +185,7 @@ private fun RenderDiscoverFeed(
|
||||
viewModel: FeedViewModel,
|
||||
routeForLastRead: String?,
|
||||
forceEventKind: Int?,
|
||||
listState: LazyListState,
|
||||
listState: ScrollableState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
@@ -189,14 +210,25 @@ private fun RenderDiscoverFeed(
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
DiscoverFeedLoaded(
|
||||
state,
|
||||
routeForLastRead,
|
||||
listState,
|
||||
forceEventKind,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
if (listState is LazyGridState) {
|
||||
DiscoverFeedColumnsLoaded(
|
||||
state,
|
||||
routeForLastRead,
|
||||
listState,
|
||||
forceEventKind,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
} else if (listState is LazyListState) {
|
||||
DiscoverFeedLoaded(
|
||||
state,
|
||||
routeForLastRead,
|
||||
listState,
|
||||
forceEventKind,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.Loading -> {
|
||||
@@ -208,6 +240,7 @@ private fun RenderDiscoverFeed(
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForDiscoveryScreen(
|
||||
discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
|
||||
@@ -217,6 +250,7 @@ fun WatchAccountForDiscoveryScreen(
|
||||
|
||||
LaunchedEffect(accountViewModel, listState) {
|
||||
NostrDiscoveryDataSource.resetFilters()
|
||||
discoverMarketplaceFeedViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
@@ -260,7 +294,7 @@ private fun DiscoverFeedLoaded(
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun DiscoverFeedTwoColumnsLoaded(
|
||||
private fun DiscoverFeedColumnsLoaded(
|
||||
state: FeedState.Loaded,
|
||||
routeForLastRead: String?,
|
||||
listState: LazyGridState,
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
@@ -12,6 +15,7 @@ import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -78,12 +82,12 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -166,6 +170,11 @@ fun MainScreen(
|
||||
factory = NostrVideoFeedViewModel.Factory(accountViewModel.account)
|
||||
)
|
||||
|
||||
val discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel = viewModel(
|
||||
key = "NostrDiscoveryMarketplaceFeedViewModel",
|
||||
factory = NostrDiscoverMarketplaceFeedViewModel.Factory(accountViewModel.account)
|
||||
)
|
||||
|
||||
val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = viewModel(
|
||||
key = "NostrDiscoveryLiveFeedViewModel",
|
||||
factory = NostrDiscoverLiveFeedViewModel.Factory(accountViewModel.account)
|
||||
@@ -302,10 +311,8 @@ fun MainScreen(
|
||||
bottomBar = {
|
||||
AnimatedContent(
|
||||
targetState = shouldShow.value,
|
||||
transitionSpec = {
|
||||
slideInVertically { height -> height } togetherWith
|
||||
slideOutVertically { height -> height }
|
||||
}
|
||||
transitionSpec = AnimatedContentTransitionScope<Boolean>::bottomBarTransitionSpec,
|
||||
label = "BottomBarAnimatedContent"
|
||||
) { isVisible ->
|
||||
if (isVisible) {
|
||||
AppBottomBar(accountViewModel, navState, navBottomRow)
|
||||
@@ -315,10 +322,8 @@ fun MainScreen(
|
||||
topBar = {
|
||||
AnimatedContent(
|
||||
targetState = shouldShow.value,
|
||||
transitionSpec = {
|
||||
slideInVertically { height -> 0 } togetherWith
|
||||
slideOutVertically { height -> 0 }
|
||||
}
|
||||
transitionSpec = AnimatedContentTransitionScope<Boolean>::topBarTransitionSpec,
|
||||
label = "TopBarAnimatedContent"
|
||||
) { isVisible ->
|
||||
if (isVisible) {
|
||||
AppTopBar(
|
||||
@@ -339,7 +344,7 @@ fun MainScreen(
|
||||
exit = remember { scaleOut() }
|
||||
) {
|
||||
Box(
|
||||
Size55Modifier
|
||||
modifier = Modifier.defaultMinSize(minWidth = 55.dp, minHeight = 55.dp)
|
||||
) {
|
||||
FloatingButtons(
|
||||
navState,
|
||||
@@ -365,6 +370,7 @@ fun MainScreen(
|
||||
knownFeedViewModel = knownFeedViewModel,
|
||||
newFeedViewModel = newFeedViewModel,
|
||||
videoFeedViewModel = videoFeedViewModel,
|
||||
discoverMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
|
||||
@@ -396,6 +402,23 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
private fun <S> AnimatedContentTransitionScope<S>.topBarTransitionSpec(): ContentTransform {
|
||||
return topBarAnimation
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
private fun <S> AnimatedContentTransitionScope<S>.bottomBarTransitionSpec(): ContentTransform {
|
||||
return bottomBarAnimation
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
val topBarAnimation: ContentTransform =
|
||||
slideInVertically { height -> 0 } togetherWith slideOutVertically { height -> 0 }
|
||||
|
||||
val bottomBarAnimation: ContentTransform =
|
||||
slideInVertically { height -> height } togetherWith slideOutVertically { height -> height }
|
||||
|
||||
@Composable
|
||||
private fun DisplayErrorMessages(accountViewModel: AccountViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -115,8 +115,9 @@ fun VideoScreen(
|
||||
@Composable
|
||||
fun WatchAccountForVideoScreen(videoFeedView: NostrVideoFeedViewModel, accountViewModel: AccountViewModel) {
|
||||
val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle()
|
||||
val hiddenUsers = accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(accountViewModel, listState) {
|
||||
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
|
||||
NostrVideoDataSource.resetFilters()
|
||||
videoFeedView.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
@@ -193,6 +194,7 @@ private fun LoadedState(
|
||||
SlidingCarousel(
|
||||
state.feed,
|
||||
pagerState,
|
||||
state.showHidden.value,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
@@ -204,6 +206,7 @@ private fun LoadedState(
|
||||
fun SlidingCarousel(
|
||||
feed: MutableState<ImmutableList<Note>>,
|
||||
pagerState: PagerState,
|
||||
showHidden: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
@@ -216,7 +219,7 @@ fun SlidingCarousel(
|
||||
}
|
||||
) { index ->
|
||||
feed.value.getOrNull(index)?.let { note ->
|
||||
LoadedVideoCompose(note, accountViewModel, nav)
|
||||
LoadedVideoCompose(note, showHidden, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +227,7 @@ fun SlidingCarousel(
|
||||
@Composable
|
||||
fun LoadedVideoCompose(
|
||||
note: Note,
|
||||
showHidden: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
@@ -233,17 +237,19 @@ fun LoadedVideoCompose(
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
if (!showHidden) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchForReports(note, accountViewModel) { newState ->
|
||||
if (state != newState) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
state = newState
|
||||
WatchForReports(note, accountViewModel) { newState ->
|
||||
if (state != newState) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
state = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = state) {
|
||||
Crossfade(targetState = state, label = "LoadedVideoCompose") {
|
||||
RenderReportState(
|
||||
it,
|
||||
note,
|
||||
@@ -262,7 +268,7 @@ fun RenderReportState(
|
||||
) {
|
||||
var showReportedNote by remember { mutableStateOf(false) }
|
||||
|
||||
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
|
||||
Crossfade(targetState = (!state.isAcceptable || state.isHiddenAuthor) && !showReportedNote) { showHiddenNote ->
|
||||
if (showHiddenNote) {
|
||||
Column(remember { Modifier.fillMaxSize() }, verticalArrangement = Arrangement.Center) {
|
||||
HiddenNote(
|
||||
|
||||
@@ -27,6 +27,7 @@ val BottomTopHeight = Modifier.height(50.dp)
|
||||
val TabRowHeight = Modifier
|
||||
|
||||
val SmallBorder = RoundedCornerShape(7.dp)
|
||||
val SmallishBorder = RoundedCornerShape(9.dp)
|
||||
val QuoteBorder = RoundedCornerShape(15.dp)
|
||||
val ButtonBorder = RoundedCornerShape(20.dp)
|
||||
val EditFieldBorder = RoundedCornerShape(25.dp)
|
||||
@@ -148,3 +149,12 @@ val EditFieldLeadingIconModifier = Modifier
|
||||
val ZeroPadding = PaddingValues(0.dp)
|
||||
val FeedPadding = PaddingValues(top = 10.dp, bottom = 10.dp)
|
||||
val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
|
||||
val profileContentHeaderModifier = Modifier.fillMaxWidth().padding(top = 70.dp, start = Size25dp, end = Size25dp)
|
||||
val bannerModifier = Modifier.fillMaxWidth().height(120.dp)
|
||||
val drawerSpacing = Modifier.padding(top = Size10dp, start = Size25dp, end = Size25dp)
|
||||
|
||||
val IconRowTextModifier = Modifier.padding(start = 16.dp)
|
||||
val IconRowModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 15.dp, horizontal = 25.dp)
|
||||
|
||||
@@ -37,17 +37,17 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
private val DarkColorPalette = darkColorScheme(
|
||||
primary = Purple200,
|
||||
secondary = Teal200,
|
||||
// secondary = Purple700,
|
||||
tertiary = Teal200,
|
||||
background = Color(0xFF000000),
|
||||
surface = Color(0xFF000000)
|
||||
surface = Color(0xFF000000),
|
||||
surfaceVariant = Color(red = 29, green = 26, blue = 34)
|
||||
)
|
||||
|
||||
private val LightColorPalette = lightColorScheme(
|
||||
primary = Purple500,
|
||||
secondary = Teal200,
|
||||
// secondary = Purple700,
|
||||
tertiary = Teal200
|
||||
tertiary = Teal200,
|
||||
surfaceVariant = Color(red = 250, green = 245, blue = 252)
|
||||
)
|
||||
|
||||
private val DarkNewItemBackground = DarkColorPalette.primary.copy(0.12f)
|
||||
|
||||
@@ -33,6 +33,7 @@ val Typography = Typography(
|
||||
val Font12SP = 12.sp
|
||||
val Font14SP = 14.sp
|
||||
val Font17SP = 17.sp
|
||||
val Font18SP = 18.sp
|
||||
|
||||
val MarkdownTextStyle = TextStyle(lineHeight = 1.30.em)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -428,7 +428,7 @@ NIP جديد: قد لا يراه العملاء الآخرون</string>
|
||||
<string name="error_dialog_button_ok">موافق</string>
|
||||
<string name="relay_information_document_error_assemble_url">فشل الوصول إلى
|
||||
%1$s: %2$s</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sats أرسلت الى محفظتك
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats أرسلت الى محفظتك
|
||||
(الرسوم: sats %2$s ) </string>
|
||||
<string name="error_unable_to_fetch_invoice">تعذر جلب الinvoice من خوادم المستلم</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">موفر wallet connect الخاص بك أعاد الخطأ التالي: %1$s</string>
|
||||
|
||||
@@ -487,8 +487,8 @@
|
||||
<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="zap_split_search_and_add_user">ব্যবহারকারীদের অনুসন্ধান করুন এবং যুক্ত করুন</string>
|
||||
<string name="zap_split_search_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>
|
||||
|
||||
@@ -406,6 +406,9 @@
|
||||
<string name="payment">Platba</string>
|
||||
<string name="cashu">Cashu Token</string>
|
||||
<string name="cashu_redeem">Vyměnit</string>
|
||||
<string name="cashu_redeem_to_zap">Poslat do Zap peněženky</string>
|
||||
<string name="cashu_redeem_to_cashu">Otevřít v Cashu Wallet</string>
|
||||
<string name="cashu_copy_token">Kopírovat token</string>
|
||||
<string name="no_lightning_address_set">Není nastavena žádná Lightning adresa</string>
|
||||
<string name="copied_token_to_clipboard">Token zkopírován do schránky</string>
|
||||
<string name="live_stream_live_tag">ŽIVĚ</string>
|
||||
@@ -417,6 +420,7 @@
|
||||
<string name="are_you_sure_you_want_to_log_out">Odhlášení vymaže všechny vaše místní informace. Ujistěte se, že máte zálohované své privátní klíče, abyste se vyhnuli ztrátě účtu. Chcete pokračovat?</string>
|
||||
<string name="followed_tags">Sledované značky</string>
|
||||
<string name="relay_setup">Rele</string>
|
||||
<string name="discover_marketplace">Trh</string>
|
||||
<string name="discover_live">Živě</string>
|
||||
<string name="discover_community">Komunita</string>
|
||||
<string name="discover_chat">Chaty</string>
|
||||
@@ -475,6 +479,7 @@
|
||||
<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_to_clipboard">Kopírovat do schránky</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>
|
||||
@@ -498,8 +503,8 @@
|
||||
<string name="active_for_search">Hledání</string>
|
||||
<string name="zap_split_title">Rozdělit a přeposlat Zaps</string>
|
||||
<string name="zap_split_explainer">Podporované klienty budou Zapy rozdělovat a přeposílat uživatelům přidaným zde místo vám</string>
|
||||
<string name="zap_split_serarch_and_add_user">Hledat a přidat uživatele</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Uživatelské jméno nebo zobrazované jméno</string>
|
||||
<string name="zap_split_search_and_add_user">Hledat a přidat uživatele</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Uživatelské jméno nebo zobrazované jméno</string>
|
||||
<string name="missing_lud16">Chybí nastavení blesku</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Uživatel %1$s nemá nastavenou bleskovou adresu pro přijímání satoshi</string>
|
||||
<string name="zap_split_weight">Procenta</string>
|
||||
@@ -528,8 +533,9 @@
|
||||
<string name="cashu_failed_redemption">Nepodařilo se použít Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint poskytl následující chybovou zprávu: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Tokeny Cashu již byly utraceny.</string>
|
||||
<string name="cashu_sucessful_redemption">Obdrženo Cashu</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sats bylo odesláno do vaší peněženky. (Poplatek: %2$s sats)</string>
|
||||
<string name="cashu_successful_redemption">Obdrženo Cashu</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats bylo odesláno do vaší peněženky. (Poplatek: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">V systému nebyla nalezena žádná kompatibilní peněženka Cashu</string>
|
||||
<string name="error_unable_to_fetch_invoice">Nebylo možné načíst fakturu ze serverů příjemce</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Poskytovatel připojení vaší peněženky vrátil následující chybu: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Nepodařilo se připojit k Tor</string>
|
||||
@@ -557,4 +563,41 @@
|
||||
Po instalaci vyberte aplikaci kterou chcete používat v Nastavení.
|
||||
</string>
|
||||
<string name="payment_required_title">Zpráva od %1$s</string>
|
||||
<string name="thread_title">Vlákno</string>
|
||||
<string name="send_the_seller_a_message">Poslat prodejci zprávu</string>
|
||||
<string name="hi_seller_is_this_still_available">Ahoj %1$s, je to stále k dispozici?</string>
|
||||
<string name="hi_there_is_this_still_available">Ahoj, je to stále k dispozici?</string>
|
||||
<string name="classifieds">Prodat předmět</string>
|
||||
<string name="classifieds_title">Název</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">Stav</string>
|
||||
<string name="classifieds_category">Kategorie</string>
|
||||
<string name="classifieds_price">Cena (v Sats)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Místo</string>
|
||||
<string name="classifieds_location_placeholder">Město, stát, země</string>
|
||||
<string name="classifieds_condition_new">Nový</string>
|
||||
<string name="classifieds_condition_new_explainer">Je to zcela nová jednotka, v původní krabici</string>
|
||||
<string name="classifieds_condition_like_new">Jako nový</string>
|
||||
<string name="classifieds_condition_like_new_explainer">Použitý, ale nejsou žádné známky použití</string>
|
||||
<string name="classifieds_condition_good">Dobrý</string>
|
||||
<string name="classifieds_condition_good_explainer">Má nějaké povrchní známky používání</string>
|
||||
<string name="classifieds_condition_fair">Slušný</string>
|
||||
<string name="classifieds_condition_fair_explainer">Má stále přijatelný a funkční stav</string>
|
||||
<string name="classifieds_category_clothing">Oblečení</string>
|
||||
<string name="classifieds_category_accessories">Příslušenství</string>
|
||||
<string name="classifieds_category_electronics">Elektronika</string>
|
||||
<string name="classifieds_category_furniture">Nábytek</string>
|
||||
<string name="classifieds_category_collectibles">Sběratelské předměty</string>
|
||||
<string name="classifieds_category_books">Knihy</string>
|
||||
<string name="classifieds_category_pets">Domácí zvířata</string>
|
||||
<string name="classifieds_category_sports">Sportovní</string>
|
||||
<string name="classifieds_category_fitness">Fitness</string>
|
||||
<string name="classifieds_category_art">Umění</string>
|
||||
<string name="classifieds_category_crafts">Vyrobí</string>
|
||||
<string name="classifieds_category_home">Domovský</string>
|
||||
<string name="classifieds_category_office">Kancelář</string>
|
||||
<string name="classifieds_category_food">Potraviny</string>
|
||||
<string name="classifieds_category_misc">Různé</string>
|
||||
<string name="classifieds_category_other">Ostatní</string>
|
||||
</resources>
|
||||
|
||||
@@ -410,6 +410,9 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="payment">Zahlung</string>
|
||||
<string name="cashu">Cashu-Token</string>
|
||||
<string name="cashu_redeem">Einlösen</string>
|
||||
<string name="cashu_redeem_to_zap">An Zap Wallet senden</string>
|
||||
<string name="cashu_redeem_to_cashu">In Cashu Wallet öffnen</string>
|
||||
<string name="cashu_copy_token">Token kopieren</string>
|
||||
<string name="no_lightning_address_set">Keine Lightning-Adresse festgelegt</string>
|
||||
<string name="copied_token_to_clipboard">Token in die Zwischenablage kopiert</string>
|
||||
<string name="live_stream_live_tag">LIVE</string>
|
||||
@@ -421,6 +424,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="are_you_sure_you_want_to_log_out">Das Abmelden löscht alle Ihre lokalen Informationen. Stellen Sie sicher, dass Sie Ihre privaten Schlüssel gesichert haben, um einen Kontoverlust zu vermeiden. Möchten Sie fortfahren?</string>
|
||||
<string name="followed_tags">Gefolgte Tags</string>
|
||||
<string name="relay_setup">Relais</string>
|
||||
<string name="discover_marketplace">Marktplatz</string>
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Gemeinschaft</string>
|
||||
<string name="discover_chat">Chats</string>
|
||||
@@ -479,6 +483,7 @@ 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_to_clipboard">In Zwischenablage kopieren</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>
|
||||
@@ -502,8 +507,8 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="active_for_search">Suche</string>
|
||||
<string name="zap_split_title">Zaps aufteilen und weiterleiten</string>
|
||||
<string name="zap_split_explainer">Unterstützende Clients werden Zaps an die hier hinzugefügten Benutzer aufteilen und weiterleiten, anstatt an Sie</string>
|
||||
<string name="zap_split_serarch_and_add_user">Benutzer suchen und hinzufügen</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Benutzername oder Anzeigename</string>
|
||||
<string name="zap_split_search_and_add_user">Benutzer suchen und hinzufügen</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Benutzername oder Anzeigename</string>
|
||||
<string name="missing_lud16">Fehlende Lightning-Konfiguration</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Benutzer %1$s hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string>
|
||||
<string name="zap_split_weight">Prozent</string>
|
||||
@@ -532,8 +537,9 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="cashu_failed_redemption">Cashu konnte nicht eingelöst werden</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint hat folgende Fehlermeldung geliefert: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Cashu-Token bereits ausgegeben.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu erhalten</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s Sats wurden an Ihre Wallet gesendet. (Gebühren: %2$s Sats)</string>
|
||||
<string name="cashu_successful_redemption">Cashu erhalten</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s Sats wurden an Ihre Wallet gesendet. (Gebühren: %2$s Sats)</string>
|
||||
<string name="cashu_no_wallet_found">Keine kompatible Cashu-Brieftasche im System gefunden</string>
|
||||
<string name="error_unable_to_fetch_invoice">Konnte Rechnung nicht vom Server des Empfängers abrufen</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Ihr Wallet-Connect-Anbieter hat folgenden Fehler zurückgegeben: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Konnte keine Verbindung zu Tor herstellen</string>
|
||||
@@ -561,4 +567,41 @@ anz der Bedingungen ist erforderlich</string>
|
||||
Nach der Installation wählen Sie die App aus, die Sie in den Einstellungen verwenden möchten.
|
||||
</string>
|
||||
<string name="payment_required_title">Nachricht von %1$s</string>
|
||||
<string name="thread_title">Faden</string>
|
||||
<string name="send_the_seller_a_message">Dem Verkäufer eine Nachricht senden</string>
|
||||
<string name="hi_seller_is_this_still_available">Hallo %1$s, ist das noch verfügbar?</string>
|
||||
<string name="hi_there_is_this_still_available">Hallo, ist das noch verfügbar?</string>
|
||||
<string name="classifieds">Artikel verkaufen</string>
|
||||
<string name="classifieds_title">Titel</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">Zustand</string>
|
||||
<string name="classifieds_category">Kategorie</string>
|
||||
<string name="classifieds_price">Preis (in Sats)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Ort</string>
|
||||
<string name="classifieds_location_placeholder">Stadt, Staat, Land</string>
|
||||
<string name="classifieds_condition_new">Neu</string>
|
||||
<string name="classifieds_condition_new_explainer">Es handelt sich um eine brandneue Einheit, in der Originalverpackung</string>
|
||||
<string name="classifieds_condition_like_new">Wie neu</string>
|
||||
<string name="classifieds_condition_like_new_explainer">Es wurde benutzt, aber es gibt keine Anzeichen von Nutzung</string>
|
||||
<string name="classifieds_condition_good">Gut</string>
|
||||
<string name="classifieds_condition_good_explainer">Es hat einige oberflächliche Gebrauchsspuren</string>
|
||||
<string name="classifieds_condition_fair">Faire</string>
|
||||
<string name="classifieds_condition_fair_explainer">Es ist immer noch in akzeptablem und funktionalem Zustand</string>
|
||||
<string name="classifieds_category_clothing">Kleidung</string>
|
||||
<string name="classifieds_category_accessories">Accessoires</string>
|
||||
<string name="classifieds_category_electronics">Elektronik</string>
|
||||
<string name="classifieds_category_furniture">Möbel</string>
|
||||
<string name="classifieds_category_collectibles">Sammlerstücke</string>
|
||||
<string name="classifieds_category_books">Bücher</string>
|
||||
<string name="classifieds_category_pets">Haustiere</string>
|
||||
<string name="classifieds_category_sports">Sport</string>
|
||||
<string name="classifieds_category_fitness">Fitness</string>
|
||||
<string name="classifieds_category_art">Kunst</string>
|
||||
<string name="classifieds_category_crafts">Handwerk</string>
|
||||
<string name="classifieds_category_home">Zuhause</string>
|
||||
<string name="classifieds_category_office">Büro</string>
|
||||
<string name="classifieds_category_food">Lebensmittel</string>
|
||||
<string name="classifieds_category_misc">Verschiedenes</string>
|
||||
<string name="classifieds_category_other">Sonstiges</string>
|
||||
</resources>
|
||||
|
||||
@@ -498,8 +498,8 @@
|
||||
<string name="active_for_search">Αναζήτηση</string>
|
||||
<string name="zap_split_title">Διαχωρισμός και προώθηση Zaps</string>
|
||||
<string name="zap_split_explainer">Οι cients θα διαχωρίσουν και θα προωθήσουν τα zaps στους χρήστες που προστέθηκαν εδώ αντί για εσάς</string>
|
||||
<string name="zap_split_serarch_and_add_user">Αναζήτηση και Προσθήκη Χρήστη</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Όνομα Χρήστη ή εμφανιζόμενο όνομα</string>
|
||||
<string name="zap_split_search_and_add_user">Αναζήτηση και Προσθήκη Χρήστη</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Όνομα Χρήστη ή εμφανιζόμενο όνομα</string>
|
||||
<string name="missing_lud16">Λείπει η ρύθμιση lightning (LN)</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Ο Χρήστης %1$s δεν έχει ορίσει διεύθυνση LN (lightning) για να λαμβάνει sats</string>
|
||||
<string name="zap_split_weight">Ποσοστό</string>
|
||||
@@ -526,8 +526,8 @@
|
||||
<string name="cashu_failed_redemption">Αδυναμία εξαργύρωσης Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Το Mint έδωσε το ακόλουθο μήνυμα σφάλματος: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Τα Cashu tokens έχουν ήδη δαπανηθεί.</string>
|
||||
<string name="cashu_sucessful_redemption">Ληφθέντα Cashu</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sats στάλθηκαν στο πορτοφόλι σας. (Κρατήσεις: %2$s sats)</string>
|
||||
<string name="cashu_successful_redemption">Ληφθέντα Cashu</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats στάλθηκαν στο πορτοφόλι σας. (Κρατήσεις: %2$s sats)</string>
|
||||
<string name="error_unable_to_fetch_invoice">Αδυναμία ανάκτησης τιμολογίου από τους διακομιστές του παραλήπτη</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Ο πάροχος σύνδεσης του πορτοφολιού σας επέστρεψε το ακόλουθο σφάλμα: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Αδυναμία σύνδεσης στο Tor</string>
|
||||
|
||||
@@ -498,8 +498,8 @@
|
||||
<string name="active_for_search">Búsqueda</string>
|
||||
<string name="zap_split_title">Dividir y reenviar zaps</string>
|
||||
<string name="zap_split_explainer">Los clientes compatibles dividirán y reenviarán los zaps a los usuarios que se agreguen aquí en lugar de a ti.</string>
|
||||
<string name="zap_split_serarch_and_add_user">Buscar y agregar usuario</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Nombre de usuario o para mostrar</string>
|
||||
<string name="zap_split_search_and_add_user">Buscar y agregar usuario</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Nombre de usuario o para mostrar</string>
|
||||
<string name="missing_lud16">Lightning no está configurado</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario %1$s no tiene una dirección de Lightning configurada para recibir sats.</string>
|
||||
<string name="zap_split_weight">Porcentaje</string>
|
||||
@@ -528,8 +528,8 @@
|
||||
<string name="cashu_failed_redemption">No se pudo canjear Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">La Mint proporcionó el siguiente mensaje de error: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Los tokens de Cashu ya se gastaron.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu recibido</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">Se enviaron %1$s sats a tu billetera (comisión: %2$s sats).</string>
|
||||
<string name="cashu_successful_redemption">Cashu recibido</string>
|
||||
<string name="cashu_successful_redemption_explainer">Se enviaron %1$s sats a tu billetera (comisión: %2$s sats).</string>
|
||||
<string name="error_unable_to_fetch_invoice">No se puede obtener la factura de los servidores del destinatario</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">El proveedor de conexión de billetera devolvió el siguiente error: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">No se pudo conectar a Tor</string>
|
||||
|
||||
@@ -498,8 +498,8 @@
|
||||
<string name="active_for_search">Búsqueda</string>
|
||||
<string name="zap_split_title">Dividir y reenviar zaps</string>
|
||||
<string name="zap_split_explainer">Los clientes compatibles dividirán y reenviarán los zaps a los usuarios que se agreguen aquí en lugar de a ti.</string>
|
||||
<string name="zap_split_serarch_and_add_user">Buscar y agregar usuario</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Nombre de usuario o para mostrar</string>
|
||||
<string name="zap_split_search_and_add_user">Buscar y agregar usuario</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Nombre de usuario o para mostrar</string>
|
||||
<string name="missing_lud16">Lightning no está configurado</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario %1$s no tiene una dirección de Lightning configurada para recibir sats.</string>
|
||||
<string name="zap_split_weight">Porcentaje</string>
|
||||
@@ -528,8 +528,8 @@
|
||||
<string name="cashu_failed_redemption">No se pudo canjear Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">La Mint proporcionó el siguiente mensaje de error: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Los tokens de Cashu ya se gastaron.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu recibido</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">Se enviaron %1$s sats a tu billetera (comisión: %2$s sats).</string>
|
||||
<string name="cashu_successful_redemption">Cashu recibido</string>
|
||||
<string name="cashu_successful_redemption_explainer">Se enviaron %1$s sats a tu billetera (comisión: %2$s sats).</string>
|
||||
<string name="error_unable_to_fetch_invoice">No se puede obtener la factura de los servidores del destinatario</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">El proveedor de conexión de billetera devolvió el siguiente error: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">No se pudo conectar a Tor</string>
|
||||
|
||||
@@ -497,8 +497,8 @@
|
||||
<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="zap_split_search_and_add_user">جستجو و افزودن کاربر</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">نام کاربری یا نام نمایشی</string>
|
||||
<string name="missing_lud16">تنظیمات لایتنینگ موجود نیست</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">کاربر %1$s آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</string>
|
||||
<string name="zap_split_weight">درصد</string>
|
||||
@@ -524,8 +524,8 @@
|
||||
<string name="cashu_failed_redemption">Cashu بازپرداخت نشد</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint پیام خطای روبرو را داد: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">توکن Cashu خرج شده است.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu دریافت شد</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s ساتوشی به کیف پول شما ارسال شد. (کارمزد: %2$s ساتوشی)</string>
|
||||
<string name="cashu_successful_redemption">Cashu دریافت شد</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s ساتوشی به کیف پول شما ارسال شد. (کارمزد: %2$s ساتوشی)</string>
|
||||
<string name="error_unable_to_fetch_invoice">صورتحساب از سرور گیرنده دریافت نشد</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">ارائه دهنده اتصال کیف پول شما خطای روبرو را داد: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">نمی توان به Tor وصل شد</string>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="profile_image">Profiilikuva</string>
|
||||
<string name="scan_qr">Skannaa QR</string>
|
||||
<string name="show_anyway">Näytä silti</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Viesti raportoitiin käyttäjän toimesta</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Viesti mykistettiin tai raportoitiin käyttäjän toimesta</string>
|
||||
<string name="post_not_found">Tapahtumaa ladataan tai sitä ei löydy relelistalta</string>
|
||||
<string name="channel_image">Kanavan kuva</string>
|
||||
<string name="referenced_event_not_found">Viitattua tapahtumaa ei löytynyt</string>
|
||||
@@ -36,6 +36,8 @@
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Kirjaudu sisään yksityisellä avaimella Zapien lähettämiseksi</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">Kirjaudu sisään yksityisellä avaimella voidaksesi seurata</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Kirjaudu sisään yksityisellä avaimella seuraamisen lopettamiseksi</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_hide_word">Käytät julkista avainta ja julkiset avaimet ovat vain luku -tilassa. Kirjaudu yksityisellä avaimella, jotta voit piilottaa sanan tai lauseen</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_show_word">Käytät julkista avainta ja julkiset avaimet ovat vain luettavissa. Kirjaudu yksityisellä avaimella, jotta voit näyttää sanan tai lauseen</string>
|
||||
<string name="zaps">Zapit</string>
|
||||
<string name="view_count">Katselukerrat</string>
|
||||
<string name="boost">Tehosta</string>
|
||||
@@ -144,6 +146,7 @@
|
||||
<string name="login">Kirjaudu sisään</string>
|
||||
<string name="generate_a_new_key">Luo uusi avain</string>
|
||||
<string name="loading_feed">Ladataan syöte</string>
|
||||
<string name="loading_account">Ladataan tiliä</string>
|
||||
<string name="error_loading_replies">"Virhe ladattaessa vastauksia: "</string>
|
||||
<string name="try_again">Yritä uudelleen</string>
|
||||
<string name="feed_is_empty">Syöte on tyhjä.</string>
|
||||
@@ -191,6 +194,8 @@
|
||||
<string name="secret_key_copied_to_clipboard">Salainen avain (nsec) kopioitu leikepöydälle</string>
|
||||
<string name="copy_my_secret_key">Kopioi salainen avain</string>
|
||||
<string name="biometric_authentication_failed">Tunnistautuminen epäonnistui</string>
|
||||
<string name="biometric_authentication_failed_explainer">Biometriikka ei pystynyt tunnistamaan tämän puhelimen omistajaa</string>
|
||||
<string name="biometric_authentication_failed_explainer_with_error">Biometriikka ei pystynyt tunnistamaan tämän puhelimen omistajaa. Virhe: %1$s</string>
|
||||
<string name="biometric_error">Virhe</string>
|
||||
<string name="badge_created_by">"Luonut %1$s"</string>
|
||||
<string name="badge_award_image_for">"Merkki kohteelle %1$s"</string>
|
||||
@@ -270,6 +275,8 @@
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time">Sulje äänestys</string>
|
||||
<string name="poll_closing_time_days">päivän kuluttua</string>
|
||||
<string name="poll_unable_to_vote">Ei voi äänestää</string>
|
||||
<string name="poll_is_closed_explainer">Äänestys on suljettu uusilta ääniltä</string>
|
||||
<string name="poll_zap_amount">Zap-määrä</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">Vain yksi ääni käyttäjää kohti tällä äänestystyypillä</string>
|
||||
<string name="looking_for_event">"Etsitään tapahtumaa %1$s"</string>
|
||||
@@ -279,6 +286,7 @@
|
||||
<string name="custom_zaps_add_a_message_example">Kiitos tehdystä työstä!</string>
|
||||
<string name="lightning_create_and_add_invoice">Luo ja lisää</string>
|
||||
<string name="poll_author_no_vote">Äänestyksen luojat eivät voi äänestää omissa äänestyksissään.</string>
|
||||
<string name="hash_verification_info_title">Mitä tämä tarkoittaa?</string>
|
||||
<string name="hash_verification_passed">Tämä sisältö on sama kuin viestin julkaisuhetkellä</string>
|
||||
<string name="hash_verification_failed">Tämä sisältö on muuttunut. Kirjoittaja ei ehkä ole nähnyt tai hyväksynyt muutosta</string>
|
||||
<string name="content_description_add_image">Lisää kuva</string>
|
||||
@@ -330,6 +338,7 @@
|
||||
<string name="follow_list_selection">Seuraa-lista</string>
|
||||
<string name="follow_list_kind3follows">Kaikki seuraajat</string>
|
||||
<string name="follow_list_global">Maailmanlaajuinen</string>
|
||||
<string name="follow_list_mute_list">Mykistä lista</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown"> ## Yhdistä Torin kautta Orbotin avulla
|
||||
\n\n1. Asenna [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Käynnistä Orbot
|
||||
@@ -395,10 +404,13 @@
|
||||
<string name="minimum_pow">Vähimmäis PoW</string>
|
||||
<string name="auth">Autentikointi</string>
|
||||
<string name="payment">Maksu</string>
|
||||
<string name="cashu">Cashu-tunniste</string>
|
||||
<string name="cashu">Cashu-tunnus</string>
|
||||
<string name="cashu_redeem">Lunasta</string>
|
||||
<string name="cashu_redeem_to_zap">Lähetä zap-lompakkoon</string>
|
||||
<string name="cashu_redeem_to_cashu">Avaa Cashu-lompakossa</string>
|
||||
<string name="cashu_copy_token">Kopioi tunnus</string>
|
||||
<string name="no_lightning_address_set">Ei Lightning-osoitetta asetettuna</string>
|
||||
<string name="copied_token_to_clipboard">Kopioitu tunniste leikepöydälle</string>
|
||||
<string name="copied_token_to_clipboard">Kopioitu tunnus leikepöydälle</string>
|
||||
<string name="live_stream_live_tag">LIVE</string>
|
||||
<string name="live_stream_offline_tag">POISSA</string>
|
||||
<string name="live_stream_ended_tag">PÄÄTTYNYT</string>
|
||||
@@ -408,6 +420,7 @@
|
||||
<string name="are_you_sure_you_want_to_log_out">Uloskirjautuminen poistaa kaikki paikalliset tiedot. Varmista, että sinulla on varmuuskopio yksityisavaimistasi välttääksesi tilisi menetyksen. Haluatko jatkaa?</string>
|
||||
<string name="followed_tags">Seuratut tagit</string>
|
||||
<string name="relay_setup">Releet</string>
|
||||
<string name="discover_marketplace">Markkinapaikka</string>
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Yhteisö</string>
|
||||
<string name="discover_chat">Keskustelut</string>
|
||||
@@ -429,6 +442,8 @@
|
||||
<string name="automatically_load_images_gifs">Kuvan esikatselu</string>
|
||||
<string name="automatically_play_videos">Videon toisto</string>
|
||||
<string name="automatically_show_url_preview">URL-esikatselu</string>
|
||||
<string name="automatically_hide_nav_bars">Immersiivinen vieritys</string>
|
||||
<string name="automatically_hide_nav_bars_description">Piilota navigointipalkit vieritettäessä</string>
|
||||
<string name="load_image">Lataa kuva</string>
|
||||
<string name="spamming_users">Roskapostittajat</string>
|
||||
<string name="muted_button">Ääni pois päältä. Napsauta palauttaaksesi äänet</string>
|
||||
@@ -457,16 +472,19 @@
|
||||
<string name="messages_group_descriptor">Tämän ryhmän jäsenet</string>
|
||||
<string name="messages_new_subject_message">Selitys jäsenille</string>
|
||||
<string name="messages_new_subject_message_placeholder">Muuta nimi uusille tavoitteille.</string>
|
||||
<string name="paste_from_clipboard">Liitä leikepöydältä</string>
|
||||
<string name="language_description">Sovelluksen käyttöliittymälle</string>
|
||||
<string name="theme_description">Tumma, vaalea tai järjestelmäteema</string>
|
||||
<string name="automatically_load_images_gifs_description">Lataa kuvat ja GIF-animaatiot automaattisesti</string>
|
||||
<string name="automatically_play_videos_description">Toista videot ja GIF-animaatiot automaattisesti</string>
|
||||
<string name="automatically_show_url_preview_description">Näytä URL-esikatselut</string>
|
||||
<string name="load_image_description">Koska kuvat ladataan</string>
|
||||
<string name="copy_to_clipboard">Kopioi leikepöydälle</string>
|
||||
<string name="copy_url_to_clipboard">Kopioi URL leikepöydälle</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopioi viestin tunniste leikepöydälle</string>
|
||||
<string name="created_at">Luotu</string>
|
||||
<string name="rules">Säännöt</string>
|
||||
<string name="login_with_external_signer">Kirjaudu sisään Amberilla</string>
|
||||
<string name="status_update">Päivitä tilaasi</string>
|
||||
<string name="lightning_wallets_not_found">Virhe tulkittaessa virhesanomaa</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Äänet painotetaan zappin määrän perusteella. Voit asettaa minimimäärän roskapostin välttämiseksi ja maksimimäärän suurten zappereiden estämiseksi. Käytä samaa määrää molemmissa kentissä, jotta jokainen ääni arvostetaan samalla määrällä. Jätä se tyhjäksi hyväksyäksesi minkä tahansa määrän.</string>
|
||||
@@ -483,4 +501,102 @@
|
||||
<string name="active_for_chats">Keskustelut</string>
|
||||
<string name="active_for_global">Yleinen</string>
|
||||
<string name="active_for_search">Haku</string>
|
||||
<string name="zap_split_title">Jaa ja ohjaa zapit</string>
|
||||
<string name="zap_split_explainer">Asiakkaiden tukeminen jakaa ja välittää zapit käyttäjille, jotka on lisätty tähän sinun sijasta</string>
|
||||
<string name="zap_split_search_and_add_user">Etsi ja lisää käyttäjä</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Käyttäjänimi tai näyttönimi</string>
|
||||
<string name="missing_lud16">Lightning asetukset puuttuvat</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Käyttäjällä %1$s ei ole Lightning-osoitetta määritettynä vastaanottamaan satseja</string>
|
||||
<string name="zap_split_weight">Prosenttiosuus</string>
|
||||
<string name="zap_split_weight_placeholder">25</string>
|
||||
<string name="splitting_zaps_with">Zappien jakaminen</string>
|
||||
<string name="forwarding_zaps_to">Ohjaa zappit kohteeseen</string>
|
||||
<string name="lightning_wallets_not_found2">Lightning-lompakkoja ei löytynyt</string>
|
||||
<string name="paid">Maksettu</string>
|
||||
<string name="wallet_number">Lompakko %1$s</string>
|
||||
<string name="error_opening_external_signer">Virhe avattaessa allekirjoitussovellusta</string>
|
||||
<string name="error_opening_external_signer_description">Allekirjoittajasovellusta ei löytynyt. Tarkista, että sovellusta ei ole poistettu</string>
|
||||
<string name="sign_request_rejected">Allekirjoittajasovellus hylätty</string>
|
||||
<string name="sign_request_rejected_description">Varmista, että allekirjoittajasovellus on valtuuttanut tämän tapahtuman</string>
|
||||
<string name="no_wallet_found_with_error">Ei löytynyt lompakkoja Lightning-laskun maksamiseksi (käyttäjä: %1$s). Asenna Lightning-lompakko zapatäksesi</string>
|
||||
<string name="no_wallet_found">Ei löytynyt lompakkoja Lightning-laskun maksamiseksi. Asenna Lightning-lompakko zapatäksesi</string>
|
||||
<string name="hidden_words">Piilotetut sanat</string>
|
||||
<string name="hide_new_word_label">Piilota uusi sana tai lause</string>
|
||||
<string name="automatically_show_profile_picture">Profiilikuva</string>
|
||||
<string name="automatically_show_profile_picture_description">Näytä profiilikuvat</string>
|
||||
<string name="select_an_option">Valitse vaihtoehto</string>
|
||||
<string name="error_dialog_pay_invoice_error">Laskua ei voitu maksaa</string>
|
||||
<string name="error_dialog_pay_withdraw_error">Ei voitu perua</string>
|
||||
<string name="error_parsing_nip47_title">Wallet Connectin asennus epäonnistui</string>
|
||||
<string name="error_parsing_nip47">Virhe jäsennettäessä NIP-47 yhteyttä. Tarkista, onko tämä oikein lompakon tarjoajallasi: %1$s. Virhe: %2$s</string>
|
||||
<string name="error_parsing_nip47_no_error">Error parsing NIP-47 connection string. Check if this is correct with your Wallet provider: %1$s.</string>
|
||||
<string name="cashu_failed_redemption">Cashu epäonnistui lunastuksessa</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Paja (mint) antoi seuraavan virheilmoituksen: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Cashu tunnukset on jo käytetty.</string>
|
||||
<string name="cashu_successful_redemption">Cashu vastaanotettu</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats were sent to your wallet. (Fees: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">Yhteensopivaa Cashu-lompakkoa ei löytynyt järjestelmästä</string>
|
||||
<string name="error_unable_to_fetch_invoice">Laskua ei voi noutaa vastaanottimen palvelimilta</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Lompakkosi yhteyden tarjoaja palautti seuraavan virheen: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Toriin ei saatu yhteyttä</string>
|
||||
<string name="unable_to_download_relay_document">Reledokumenttia ei saatu ladattua</string>
|
||||
<string name="could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup">Ei voitu koota LNUrlia Lightning-osoitteesta \"%1$s\". Tarkista käyttäjän asetukset</string>
|
||||
<string name="the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct">Vastaanottajan Lightning-palvelu osoitteessa %1$s ei ole käytettävissä. Se laskettiin Lightning-osoitteesta \"%2$s\". Virhe: %3$s. Tarkista, onko palvelin ylhäällä ja onko Lightning-osoite oikein</string>
|
||||
<string name="could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct">Ei voitu selvittää %1$s. Tarkista, oletko yhteydessä, onko palvelin ylhäällä ja onko Lightning-osoite %2$s oikein</string>
|
||||
<string name="could_not_fetch_invoice_from">Ei voitu noutaa laskua kohteesta %1$s</string>
|
||||
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup">Virhe jäsennettäessä JSONia Lightning-osoitteesta. Tarkista käyttäjän Lightning-asetukset</string>
|
||||
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration">Takaisinkutsun URL-osoitetta ei löydy käyttäjän Lightning-osoitteen palvelimen asetuksista</string>
|
||||
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">Virhe jäsennettäessä JSONia Lightning-osoitteen laskun noudossa. Tarkista käyttäjän Lightning-asetukset</string>
|
||||
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">Virheellinen laskun määrä (%1$s satsia) kohteesta %2$s. Sen olisi pitänyt olla %3$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">Ei voitu luoda Lightning-laskua ennen zapin lähettämistä. Vastaanottajan Lightning-lompakko lähetti seuraavan virheen: %1$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">Ei voitu luoda Lightning-laskua ennen zapin lähettämistä. Elementtiä pr ei löydy vastauksena tulleesta JSONista.</string>
|
||||
<string name="read_only_user">Vain luku -käyttäjä</string>
|
||||
<string name="no_reactions_setup">Ei reaktioita asetettu</string>
|
||||
<string name="select_push_server">Valitse UnifiedPush-sovellus</string>
|
||||
<string name="push_server_title">Push-ilmoitus</string>
|
||||
<string name="push_server_explainer">Asennetuista UnifiedPush-sovelluksista</string>
|
||||
<string name="push_server_none">Ei mitään</string>
|
||||
<string name="push_server_none_explainer">Poistaa Push-ilmoitukset käytöstä</string>
|
||||
<string name="push_server_uses_app_explainer">Käyttää sovellusta %1$s</string>
|
||||
<string name="push_server_install_app">Push-ilmoituksen asennus</string>
|
||||
<string name="push_server_install_app_description"> Saadaksesi push-ilmoituksia, asenna mikä tahansa sovellus, joka tukee Unified Push:ia, kuten Nfty.
|
||||
Asennuksen jälkeen valitse Asetuksista sovellus, jota haluat käyttää.</string>
|
||||
<string name="payment_required_title">Viesti käyttäjältä %1$s</string>
|
||||
<string name="thread_title">Keskusteluketju</string>
|
||||
<string name="send_the_seller_a_message">Lähetä myyjälle viesti</string>
|
||||
<string name="hi_seller_is_this_still_available">Hei %1$s, onko tämä vielä saatavilla?</string>
|
||||
<string name="hi_there_is_this_still_available">Hei, onko tämä vielä saatavilla?</string>
|
||||
<string name="classifieds">Myy esine</string>
|
||||
<string name="classifieds_title">Otsikko</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">Kunto</string>
|
||||
<string name="classifieds_category">Kategoria</string>
|
||||
<string name="classifieds_price">Hinta (satseissa)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Sijainti</string>
|
||||
<string name="classifieds_location_placeholder">Kaupunki, osavaltio, maa</string>
|
||||
<string name="classifieds_condition_new">Uusi</string>
|
||||
<string name="classifieds_condition_new_explainer">Se on upouusi, alkuperäisessä pakkauksessa</string>
|
||||
<string name="classifieds_condition_like_new">Kuin uusi</string>
|
||||
<string name="classifieds_condition_like_new_explainer">Sitä on käytetty, mutta käytön merkkejä ei ole</string>
|
||||
<string name="classifieds_condition_good">Hyvä</string>
|
||||
<string name="classifieds_condition_good_explainer">Siinä on joitakin pinnallisia käytön jälkiä</string>
|
||||
<string name="classifieds_condition_fair">Reilu</string>
|
||||
<string name="classifieds_condition_fair_explainer">Se on edelleen hyväksyttävässä ja toimivassa kunnossa</string>
|
||||
<string name="classifieds_category_clothing">Vaatetus</string>
|
||||
<string name="classifieds_category_accessories">Asusteet</string>
|
||||
<string name="classifieds_category_electronics">Elektroniikka</string>
|
||||
<string name="classifieds_category_furniture">Huonekalut</string>
|
||||
<string name="classifieds_category_collectibles">Keräilyesineet</string>
|
||||
<string name="classifieds_category_books">Kirjat</string>
|
||||
<string name="classifieds_category_pets">Lemmikit</string>
|
||||
<string name="classifieds_category_sports">Urheilu</string>
|
||||
<string name="classifieds_category_fitness">Kuntoilu</string>
|
||||
<string name="classifieds_category_art">Taide</string>
|
||||
<string name="classifieds_category_crafts">Käsityöt</string>
|
||||
<string name="classifieds_category_home">Koti</string>
|
||||
<string name="classifieds_category_office">Toimisto</string>
|
||||
<string name="classifieds_category_food">Ruoka</string>
|
||||
<string name="classifieds_category_misc">Sekalaiset</string>
|
||||
<string name="classifieds_category_other">Muu</string>
|
||||
</resources>
|
||||
|
||||
@@ -338,6 +338,7 @@
|
||||
<string name="follow_list_selection">Liste des Abonnés</string>
|
||||
<string name="follow_list_kind3follows">Tous les abonnements</string>
|
||||
<string name="follow_list_global">Général</string>
|
||||
<string name="follow_list_mute_list">Liste en Sourdine</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown"> ## Se connecter à travers Tor avec Orbot
|
||||
\n\n1. Installe [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Démarre Orbot
|
||||
@@ -405,6 +406,7 @@
|
||||
<string name="payment">Paiement</string>
|
||||
<string name="cashu">Jeton Cashu</string>
|
||||
<string name="cashu_redeem">Échanger</string>
|
||||
<string name="cashu_copy_token">Copier le Jeton</string>
|
||||
<string name="no_lightning_address_set">Adresse Lightning non définie</string>
|
||||
<string name="copied_token_to_clipboard">Jeton copié dans le presse-papiers</string>
|
||||
<string name="live_stream_live_tag">EN DIRECT</string>
|
||||
@@ -416,6 +418,7 @@
|
||||
<string name="are_you_sure_you_want_to_log_out">Se déconnecter supprime toutes vos informations locales. Assurez-vous d\'avoir vos clés privées sauvegardées pour éviter de perdre votre compte. Voulez-vous continuer ?</string>
|
||||
<string name="followed_tags">Tags suivis</string>
|
||||
<string name="relay_setup">Relais</string>
|
||||
<string name="discover_marketplace">Place de Marché</string>
|
||||
<string name="discover_live">Direct</string>
|
||||
<string name="discover_community">Communauté</string>
|
||||
<string name="discover_chat">Salons</string>
|
||||
@@ -474,6 +477,7 @@
|
||||
<string name="automatically_play_videos_description">Lire automatiquement les vidéos et les GIFs</string>
|
||||
<string name="automatically_show_url_preview_description">Afficher la prévisualisation d\'URL</string>
|
||||
<string name="load_image_description">Quand charger les images</string>
|
||||
<string name="copy_to_clipboard">Copier dans le presse-papiers</string>
|
||||
<string name="copy_url_to_clipboard">Copier l\'URL dans le presse-papiers</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copier l\'ID de la note dans le presse-papiers</string>
|
||||
<string name="created_at">Créé le</string>
|
||||
@@ -497,8 +501,8 @@
|
||||
<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="zap_split_search_and_add_user">Rechercher et Ajouter un Utilisateur</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Nom d\'utilisateur ou nom affiché</string>
|
||||
<string name="missing_lud16">Configuration lightning manquante</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>
|
||||
@@ -509,7 +513,9 @@
|
||||
<string name="paid">Payé</string>
|
||||
<string name="wallet_number">Portefeuille %1$s</string>
|
||||
<string name="error_opening_external_signer">Erreur lors de l\'ouverture de l\'application de signature</string>
|
||||
<string name="error_opening_external_signer_description">L\'application signataire est introuvable. Vérifiez que l\'application n\'a pas été désinstallée</string>
|
||||
<string name="sign_request_rejected">Demande de signature rejetée</string>
|
||||
<string name="sign_request_rejected_description">Assurez-vous que l\'application signataire a autorisé cette transaction</string>
|
||||
<string name="no_wallet_found_with_error">Aucun portefeuille trouvé pour payer une facture lightning (Erreur : %1$s). Veuillez installer un portefeuille Lightning pour utiliser les zaps</string>
|
||||
<string name="no_wallet_found">Aucun portefeuille trouvé pour payer une facture lightning. Veuillez installer un portefeuille Lightning pour utiliser les zaps</string>
|
||||
<string name="hidden_words">Mots Masqués</string>
|
||||
@@ -520,8 +526,8 @@
|
||||
<string name="error_dialog_pay_invoice_error">Impossible de payer la facture</string>
|
||||
<string name="error_dialog_pay_withdraw_error">Impossible de retirer</string>
|
||||
<string name="error_parsing_nip47_title">Impossible de configurer Wallet Connect</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu reçu</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sats ont été envoyés à votre portefeuille. (Frais : %2$s sats)</string>
|
||||
<string name="cashu_successful_redemption">Cashu reçu</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats ont été envoyés à votre portefeuille. (Frais : %2$s sats)</string>
|
||||
<string name="error_unable_to_fetch_invoice">Impossible de récupérer la facture depuis les serveurs du destinataire</string>
|
||||
<string name="could_not_connect_to_tor">Impossible de se connecter à Tor</string>
|
||||
<string name="unable_to_download_relay_document">Téléchargement du document relais indisponible</string>
|
||||
@@ -547,4 +553,42 @@
|
||||
<string name="push_server_install_app_description"> Pour recevoir des notifications push, installez n\'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Nfty](https://ntfy.sh/).
|
||||
Après installation, sélectionnez l\'application que vous voulez utiliser dans les Paramètres.
|
||||
</string>
|
||||
<string name="payment_required_title">Message de %1$s</string>
|
||||
<string name="thread_title">Fil de discussion</string>
|
||||
<string name="send_the_seller_a_message">Envoyer un message au vendeur</string>
|
||||
<string name="hi_seller_is_this_still_available">Bonjour %1$s, est-ce toujours disponible ?</string>
|
||||
<string name="hi_there_is_this_still_available">Bonjour, est-ce toujours disponible ?</string>
|
||||
<string name="classifieds">Vendre un Objet</string>
|
||||
<string name="classifieds_title">Titre</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">État</string>
|
||||
<string name="classifieds_category">Catégorie</string>
|
||||
<string name="classifieds_price">Prix (en Sats)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Emplacement</string>
|
||||
<string name="classifieds_location_placeholder">Ville, État, Pays</string>
|
||||
<string name="classifieds_condition_new">Neuf</string>
|
||||
<string name="classifieds_condition_new_explainer">C\'est un tout nouveau produit, dans la boîte d\'origine</string>
|
||||
<string name="classifieds_condition_like_new">Comme Neuf</string>
|
||||
<string name="classifieds_condition_like_new_explainer">Il a été utilisé, mais il n\'y a pas de signes d\'endommagement</string>
|
||||
<string name="classifieds_condition_good">Bon état</string>
|
||||
<string name="classifieds_condition_good_explainer">Il y a quelques marques d\'utilisation superficielles</string>
|
||||
<string name="classifieds_condition_fair">Correct</string>
|
||||
<string name="classifieds_condition_fair_explainer">Il est encore fonctionnel et en bon état</string>
|
||||
<string name="classifieds_category_clothing">Vêtements</string>
|
||||
<string name="classifieds_category_accessories">Accessoires</string>
|
||||
<string name="classifieds_category_electronics">Électronique</string>
|
||||
<string name="classifieds_category_furniture">Mobilier</string>
|
||||
<string name="classifieds_category_collectibles">Articles de collection</string>
|
||||
<string name="classifieds_category_books">Livres</string>
|
||||
<string name="classifieds_category_pets">Animaux de compagnie</string>
|
||||
<string name="classifieds_category_sports">Sports</string>
|
||||
<string name="classifieds_category_fitness">Fitness</string>
|
||||
<string name="classifieds_category_art">Art</string>
|
||||
<string name="classifieds_category_crafts">Artisanat</string>
|
||||
<string name="classifieds_category_home">Accueil</string>
|
||||
<string name="classifieds_category_office">Bureau</string>
|
||||
<string name="classifieds_category_food">Nourriture</string>
|
||||
<string name="classifieds_category_misc">Divers</string>
|
||||
<string name="classifieds_category_other">Autres</string>
|
||||
</resources>
|
||||
|
||||
@@ -406,6 +406,9 @@
|
||||
<string name="payment">Fizetés</string>
|
||||
<string name="cashu">Cashu Token</string>
|
||||
<string name="cashu_redeem">Beváltás</string>
|
||||
<string name="cashu_redeem_to_zap">Küldje a Zap tárcába</string>
|
||||
<string name="cashu_redeem_to_cashu">Nyissa meg a Cashu tárcában</string>
|
||||
<string name="cashu_copy_token">Token másolása</string>
|
||||
<string name="no_lightning_address_set">Nincs Lightning Cím beállítva</string>
|
||||
<string name="copied_token_to_clipboard">Token a vágólapra másolva</string>
|
||||
<string name="live_stream_live_tag">ÉLŐ</string>
|
||||
@@ -417,6 +420,7 @@
|
||||
<string name="are_you_sure_you_want_to_log_out">A Kijelentkezés minden helyben tárolt információt töröl. Bizonyosodj meg róla, hogy a privát kulcsod le van mentve, ezzel elkerülve a fiókod elvesztését. Folytatni akarod?</string>
|
||||
<string name="followed_tags">Követettek cimkéi</string>
|
||||
<string name="relay_setup">Csomópontok</string>
|
||||
<string name="discover_marketplace">Piac</string>
|
||||
<string name="discover_live">Élő</string>
|
||||
<string name="discover_community">Közösség</string>
|
||||
<string name="discover_chat">Társalgók</string>
|
||||
@@ -475,6 +479,7 @@
|
||||
<string name="automatically_play_videos_description">A videók és a GIF-ek automatikus lejátszása</string>
|
||||
<string name="automatically_show_url_preview_description">URL előnézetek megjelenítése</string>
|
||||
<string name="load_image_description">Mikor kell a képeket betölteni</string>
|
||||
<string name="copy_to_clipboard">Másolás a vágólapra</string>
|
||||
<string name="copy_url_to_clipboard">Az URL vágólapra másolása</string>
|
||||
<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>
|
||||
@@ -498,8 +503,8 @@
|
||||
<string name="active_for_search">Keresés</string>
|
||||
<string name="zap_split_title">Zap-ek megosztása és továbbítása</string>
|
||||
<string name="zap_split_explainer">A támogató kliensek a zapokat, az itt hozzáadott felhasználóknak helyetted felosztják és továbbítják</string>
|
||||
<string name="zap_split_serarch_and_add_user">Felhasználó keresése és hozzáadása</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Felhasználónév vagy megjelenítendő név</string>
|
||||
<string name="zap_split_search_and_add_user">Felhasználó keresése és hozzáadása</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Felhasználónév vagy megjelenítendő név</string>
|
||||
<string name="missing_lud16">Hiányzó lightning beállítás</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">A %1$s felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással</string>
|
||||
<string name="zap_split_weight">Százalék</string>
|
||||
@@ -528,8 +533,9 @@
|
||||
<string name="cashu_failed_redemption">Nem sikerült a Cashu-t beváltani</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">A verde a következő hibaüzenetet küldte:%1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">A Cashu tokeneket már elköltötték.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu Megkapva</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sats érkezett a tárcádba. (Díjak: %2$s sats)</string>
|
||||
<string name="cashu_successful_redemption">Cashu Megkapva</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats érkezett a tárcádba. (Díjak: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">A rendszeren nem található kompatibilis Cashu pénztárca</string>
|
||||
<string name="error_unable_to_fetch_invoice">Nem sikerült a számlát a címzett szervereiről lekérni</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">A Wallet Connect szolgáltatója a következő hibát jelezte: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Nem sikerült a Tor hálózathoz csatlakozni</string>
|
||||
@@ -556,4 +562,42 @@
|
||||
<string name="push_server_install_app_description"> A push értesítések fogadásához olyan alkalmazást telepíts, amely támogatja a [Unified Push](https://unifiedpush.org/), például az [Nfty](https://ntfy.sh/) alkalmazást.
|
||||
A telepítés után a Beállításokban válaszd ki a használni kívánt alkalmazást.
|
||||
</string>
|
||||
<string name="payment_required_title">Üzenet %1$s-tól</string>
|
||||
<string name="thread_title">Gondolatmenet</string>
|
||||
<string name="send_the_seller_a_message">Küldj az eladónak üzenetet</string>
|
||||
<string name="hi_seller_is_this_still_available">Szia %1$s, ez még aktuális?</string>
|
||||
<string name="hi_there_is_this_still_available">Szia, ez még aktuális?</string>
|
||||
<string name="classifieds">Elem eladása</string>
|
||||
<string name="classifieds_title">Cím</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">Állapot</string>
|
||||
<string name="classifieds_category">Kategória</string>
|
||||
<string name="classifieds_price">Ár (satoshiban)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Helyszín</string>
|
||||
<string name="classifieds_location_placeholder">Város, állam, ország</string>
|
||||
<string name="classifieds_condition_new">Új</string>
|
||||
<string name="classifieds_condition_new_explainer">Teljesen új készülék, eredeti dobozában</string>
|
||||
<string name="classifieds_condition_like_new">Újszerű</string>
|
||||
<string name="classifieds_condition_like_new_explainer">Használt, de a használat nyomai nincsenek rajta</string>
|
||||
<string name="classifieds_condition_good">Jó</string>
|
||||
<string name="classifieds_condition_good_explainer">Felületes használati nyomok vannak rajta</string>
|
||||
<string name="classifieds_condition_fair">Közepes</string>
|
||||
<string name="classifieds_condition_fair_explainer">Még mindig elfogadható és működőképes állapotban van</string>
|
||||
<string name="classifieds_category_clothing">Ruházat</string>
|
||||
<string name="classifieds_category_accessories">Tartozékok</string>
|
||||
<string name="classifieds_category_electronics">Elektronikai cikkek</string>
|
||||
<string name="classifieds_category_furniture">Bútor</string>
|
||||
<string name="classifieds_category_collectibles">Gyűjtemények</string>
|
||||
<string name="classifieds_category_books">Könyvek</string>
|
||||
<string name="classifieds_category_pets">Háziállatok</string>
|
||||
<string name="classifieds_category_sports">Sport</string>
|
||||
<string name="classifieds_category_fitness">Fitness</string>
|
||||
<string name="classifieds_category_art">Művészet</string>
|
||||
<string name="classifieds_category_crafts">Iparművészet</string>
|
||||
<string name="classifieds_category_home">Otthon</string>
|
||||
<string name="classifieds_category_office">Irodaszer</string>
|
||||
<string name="classifieds_category_food">Étel</string>
|
||||
<string name="classifieds_category_misc">Vegyes</string>
|
||||
<string name="classifieds_category_other">Egyéb</string>
|
||||
</resources>
|
||||
|
||||
@@ -499,8 +499,8 @@ Beri tahu kepada pemilik untuk menambahnya</string>
|
||||
<string name="active_for_search">Cari</string>
|
||||
<string name="zap_split_title">Pisah dan alihkan Zap</string>
|
||||
<string name="zap_split_explainer">Klien yang didukung akan memisah dan mengalihkan zap kepada user yang ditambahkan disini</string>
|
||||
<string name="zap_split_serarch_and_add_user">Cari dan Tambahkan Pengguna</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Username atau tampilan nama</string>
|
||||
<string name="zap_split_search_and_add_user">Cari dan Tambahkan Pengguna</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Username atau tampilan nama</string>
|
||||
<string name="missing_lud16">Lightning tidak tersedia</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">%1$s belum mengatur alamat lightning untuk menerima sat</string>
|
||||
<string name="zap_split_weight">Persentase</string>
|
||||
@@ -527,8 +527,8 @@ Beri tahu kepada pemilik untuk menambahnya</string>
|
||||
<string name="cashu_failed_redemption">Tidak dapat meredeem Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint memberikan error: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Token Cashu sudah dipakai/habis.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu Diterima</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sat telah dikirim ke dompetmu. (Biaya: %2$s sat)</string>
|
||||
<string name="cashu_successful_redemption">Cashu Diterima</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sat telah dikirim ke dompetmu. (Biaya: %2$s sat)</string>
|
||||
<string name="error_unable_to_fetch_invoice">Tidak dapat menghubung server faktur penerima</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Penyedia Wallet Connect anda memberi error: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Tidak dapat terhubung ke Tor</string>
|
||||
|
||||
@@ -474,8 +474,8 @@
|
||||
<string name="login_with_external_signer">Masuk dengan Amber</string>
|
||||
<string name="zap_split_title">Bagi dan Teruskan Zaps</string>
|
||||
<string name="zap_split_explainer">Klien pendukung akan membagi dan meneruskan zaps ke pengguna yang ditambahkan di sini, bukan kepada Anda</string>
|
||||
<string name="zap_split_serarch_and_add_user">Cari dan Tambah Pengguna</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Nama pengguna atau nama ditampilkan</string>
|
||||
<string name="zap_split_search_and_add_user">Cari dan Tambah Pengguna</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Nama pengguna atau nama ditampilkan</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Pengguna %1$s tidak memiliki alamat lightning yang diatur untuk menerima sats</string>
|
||||
<string name="zap_split_weight">Persentase</string>
|
||||
<string name="splitting_zaps_with">Bagikan zaps dengan</string>
|
||||
@@ -511,8 +511,8 @@
|
||||
<string name="cashu_failed_redemption">Tidak dapat menukarkan Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint memberikan pesan kesalahan berikut: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Token Cashu sudah habis.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu diterima</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sat telah dikirim ke dompet Anda. (Biaya: %2$s sat)</string>
|
||||
<string name="cashu_successful_redemption">Cashu diterima</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sat telah dikirim ke dompet Anda. (Biaya: %2$s sat)</string>
|
||||
<string name="error_unable_to_fetch_invoice">Tidak dapat mengambil faktur dari server penerima</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Penyedia koneksi dompet Anda menampilkan kesalahan berikut: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Tidak dapat terhubung ke Tor</string>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<string name="known">Bekend</string>
|
||||
<string name="new_requests">Nieuw verzoek</string>
|
||||
<string name="blocked_users">Geblokkeerde gebruikers</string>
|
||||
<string name="new_threads">Nieuwe notes</string>
|
||||
<string name="new_threads">Nieuwe thread</string>
|
||||
<string name="conversations">Gesprekken</string>
|
||||
<string name="notes">Notes</string>
|
||||
<string name="replies">Reacties</string>
|
||||
@@ -338,6 +338,7 @@
|
||||
<string name="follow_list_selection">Volgerslijst</string>
|
||||
<string name="follow_list_kind3follows">Iedereen</string>
|
||||
<string name="follow_list_global">Globaal</string>
|
||||
<string name="follow_list_mute_list">Mute-lijst</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown"> ## Verbind via Tor met Orbot
|
||||
\n\n1. Installeer [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Start Orbot
|
||||
@@ -405,6 +406,9 @@
|
||||
<string name="payment">Betaling</string>
|
||||
<string name="cashu">Cashu token</string>
|
||||
<string name="cashu_redeem">Inwisselen</string>
|
||||
<string name="cashu_redeem_to_zap">Stuur naar Zap wallet</string>
|
||||
<string name="cashu_redeem_to_cashu">Openen in Cashu wallet</string>
|
||||
<string name="cashu_copy_token">Token kopiëren</string>
|
||||
<string name="no_lightning_address_set">Geen Lightning-adres ingesteld</string>
|
||||
<string name="copied_token_to_clipboard">Token gekopieerd naar klembord</string>
|
||||
<string name="live_stream_live_tag">LIVE</string>
|
||||
@@ -416,6 +420,7 @@
|
||||
<string name="are_you_sure_you_want_to_log_out">Uitloggen verwijdert al je lokale informatie. Zorg ervoor dat je een back-up hebt van je geheime sleutels om te voorkomen dat je je account kwijtraakt. Wilt u doorgaan?</string>
|
||||
<string name="followed_tags">Gevolgde tags</string>
|
||||
<string name="relay_setup">Relays</string>
|
||||
<string name="discover_marketplace">Marktplaats</string>
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Community</string>
|
||||
<string name="discover_chat">Chats</string>
|
||||
@@ -474,6 +479,7 @@
|
||||
<string name="automatically_play_videos_description">Video\'s en GIF\'s automatisch afspelen</string>
|
||||
<string name="automatically_show_url_preview_description">Toon URL-voorbeelden</string>
|
||||
<string name="load_image_description">Wanneer afbeeldingen te laden</string>
|
||||
<string name="copy_to_clipboard">Kopiëren naar klembord</string>
|
||||
<string name="copy_url_to_clipboard">Kopieer URL naar klembord</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Note naar klembord kopiëren</string>
|
||||
<string name="created_at">Gemaakt op</string>
|
||||
@@ -497,8 +503,8 @@
|
||||
<string name="active_for_search">Zoeken</string>
|
||||
<string name="zap_split_title">Deel en stuur Zaps door</string>
|
||||
<string name="zap_split_explainer">Ondersteunende clients delen en sturen door naar de gebruikers toegevoegd hier in plaats van de jouwe</string>
|
||||
<string name="zap_split_serarch_and_add_user">Zoek en voeg gebruiker toe</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Gebruikersnaam of weergavenaam</string>
|
||||
<string name="zap_split_search_and_add_user">Zoek en voeg gebruiker toe</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Gebruikersnaam of weergavenaam</string>
|
||||
<string name="missing_lud16">Ontbrekende Lightning configuratie</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Gebruiker %1$s heeft geen Lightning Adress ingesteld om sats te ontvangen</string>
|
||||
<string name="zap_split_weight">Percentage</string>
|
||||
@@ -527,8 +533,9 @@
|
||||
<string name="cashu_failed_redemption">Kan Cashu niet verzilveren</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint geeft de volgende foutmelding: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Cashu tokens zijn al uitgegeven.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu ontvangen</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sats zijn naar uw wallet gestuurd. (Fees: %2$s sats)</string>
|
||||
<string name="cashu_successful_redemption">Cashu ontvangen</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats zijn naar uw wallet gestuurd. (Fees: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">Geen compatibele Cashu wallet gevonden in het systeem</string>
|
||||
<string name="error_unable_to_fetch_invoice">Kan invoice niet ophalen van de servers van de ontvanger</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">De volgende fout is geretourneerd door uw wallet connect provider: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Kan geen verbinding maken met Tor</string>
|
||||
@@ -555,4 +562,42 @@
|
||||
<string name="push_server_install_app_description"> Om push-notificaties te ontvangen, installeer een app die [Unified Push] ondersteunt](https://unifiedpush.org/), zoals [Nfty](https://ntfy.sh/).
|
||||
Na installatie selecteer de app die je wilt gebruiken in de instellingen.
|
||||
</string>
|
||||
<string name="payment_required_title">Bericht van %1$s</string>
|
||||
<string name="thread_title">Thread</string>
|
||||
<string name="send_the_seller_a_message">Stuur de verkoper een bericht</string>
|
||||
<string name="hi_seller_is_this_still_available">Hallo %1$s, is dit nog steeds beschikbaar?</string>
|
||||
<string name="hi_there_is_this_still_available">Hallo daar, is dit nog steeds beschikbaar?</string>
|
||||
<string name="classifieds">Verkoop een artikel</string>
|
||||
<string name="classifieds_title">Titel</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">Staat</string>
|
||||
<string name="classifieds_category">Categorie</string>
|
||||
<string name="classifieds_price">Prijs (in sats)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Locatie</string>
|
||||
<string name="classifieds_location_placeholder">Stad, provincie, land</string>
|
||||
<string name="classifieds_condition_new">Nieuw</string>
|
||||
<string name="classifieds_condition_new_explainer">Het is een gloednieuw artikel, in de originele doos</string>
|
||||
<string name="classifieds_condition_like_new">Als nieuw</string>
|
||||
<string name="classifieds_condition_like_new_explainer">Gebruikt, maar er zijn geen gebruikerssporen</string>
|
||||
<string name="classifieds_condition_good">Goed</string>
|
||||
<string name="classifieds_condition_good_explainer">Het heeft een aantal oppervlakkige gebruikerssporen</string>
|
||||
<string name="classifieds_condition_fair">Redelijk</string>
|
||||
<string name="classifieds_condition_fair_explainer">Het is nog steeds in acceptabele en functionele vorm</string>
|
||||
<string name="classifieds_category_clothing">Kleding</string>
|
||||
<string name="classifieds_category_accessories">Accessoires</string>
|
||||
<string name="classifieds_category_electronics">Electronica</string>
|
||||
<string name="classifieds_category_furniture">Meubels</string>
|
||||
<string name="classifieds_category_collectibles">Verzamelobjecten</string>
|
||||
<string name="classifieds_category_books">Boeken</string>
|
||||
<string name="classifieds_category_pets">Huisdieren</string>
|
||||
<string name="classifieds_category_sports">Sport</string>
|
||||
<string name="classifieds_category_fitness">Fitness</string>
|
||||
<string name="classifieds_category_art">Kunst</string>
|
||||
<string name="classifieds_category_crafts">Ambachten</string>
|
||||
<string name="classifieds_category_home">Startpagina</string>
|
||||
<string name="classifieds_category_office">Kantoor</string>
|
||||
<string name="classifieds_category_food">Eten</string>
|
||||
<string name="classifieds_category_misc">Overige</string>
|
||||
<string name="classifieds_category_other">Anders</string>
|
||||
</resources>
|
||||
|
||||
@@ -406,6 +406,9 @@
|
||||
<string name="payment">Pagamento</string>
|
||||
<string name="cashu">Token Cashu</string>
|
||||
<string name="cashu_redeem">Resgatar</string>
|
||||
<string name="cashu_redeem_to_zap">Enviar para Zap Wallet</string>
|
||||
<string name="cashu_redeem_to_cashu">Abrir na Carteira Cashu</string>
|
||||
<string name="cashu_copy_token">Copiar Token</string>
|
||||
<string name="no_lightning_address_set">Nenhum endereço Lightning definido</string>
|
||||
<string name="copied_token_to_clipboard">Token copiado para a área de transferência</string>
|
||||
<string name="live_stream_live_tag">AO VIVO</string>
|
||||
@@ -417,6 +420,7 @@
|
||||
<string name="are_you_sure_you_want_to_log_out">Sair exclui todas as suas informações locais. Certifique-se de fazer backup de suas chaves privadas para evitar a perda de sua conta. Você quer continuar?</string>
|
||||
<string name="followed_tags">Tags Seguidas</string>
|
||||
<string name="relay_setup">Relays</string>
|
||||
<string name="discover_marketplace">Mercado</string>
|
||||
<string name="discover_live">Ao vivo</string>
|
||||
<string name="discover_community">Comunidade</string>
|
||||
<string name="discover_chat">Conversas</string>
|
||||
@@ -475,6 +479,7 @@
|
||||
<string name="automatically_play_videos_description">Reproduz automaticamente vídeos e GIFs</string>
|
||||
<string name="automatically_show_url_preview_description">Mostrar visualizações de URL</string>
|
||||
<string name="load_image_description">Quando carregar imagens</string>
|
||||
<string name="copy_to_clipboard">Copiar para a Área de Transferência</string>
|
||||
<string name="copy_url_to_clipboard">Copiar URL para a área de transferência</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copiar ID da nota para a área de transferência</string>
|
||||
<string name="created_at">Criado em</string>
|
||||
@@ -498,8 +503,8 @@
|
||||
<string name="active_for_search">Pesquisa</string>
|
||||
<string name="zap_split_title">Dividir e encaminhar Zaps</string>
|
||||
<string name="zap_split_explainer">Os clientes que suportam dividirão e encaminharão zaps para os usuários adicionados aqui em vez dos seus</string>
|
||||
<string name="zap_split_serarch_and_add_user">Pesquisar e adicionar usuário</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Nome de usuário ou nome de exibição</string>
|
||||
<string name="zap_split_search_and_add_user">Pesquisar e adicionar usuário</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Nome de usuário ou nome de exibição</string>
|
||||
<string name="missing_lud16">Configuração Lightning ausente</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">O usuário %1$s não possui um endereço lightning configurado para receber sats</string>
|
||||
<string name="zap_split_weight">Percentual</string>
|
||||
@@ -528,8 +533,9 @@
|
||||
<string name="cashu_failed_redemption">Não foi possível resgatar o Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">O Mint forneceu a seguinte mensagem de erro: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Tokens Cashu já foram gastos.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu Recebido</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sats foram enviados para a sua carteira. (Taxas: %2$s sats)</string>
|
||||
<string name="cashu_successful_redemption">Cashu Recebido</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats foram enviados para a sua carteira. (Taxas: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">Nenhuma carteira Cashu compatível encontrada no sistema</string>
|
||||
<string name="error_unable_to_fetch_invoice">Não foi possível obter a fatura dos servidores do destinatário</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Seu provedor de Wallet Connect retornou o seguinte erro: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Não foi possível conectar ao Tor</string>
|
||||
@@ -557,4 +563,41 @@
|
||||
Após a instalação, selecione o aplicativo que deseja usar nas configurações.
|
||||
</string>
|
||||
<string name="payment_required_title">Mensagem de %1$s</string>
|
||||
<string name="thread_title">Linha</string>
|
||||
<string name="send_the_seller_a_message">Envie uma mensagem para o vendedor</string>
|
||||
<string name="hi_seller_is_this_still_available">Oi %1$s, ainda está disponível?</string>
|
||||
<string name="hi_there_is_this_still_available">Olá, ainda está disponível?</string>
|
||||
<string name="classifieds">Vender um Item</string>
|
||||
<string name="classifieds_title">Título</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">Condição</string>
|
||||
<string name="classifieds_category">Categoria</string>
|
||||
<string name="classifieds_price">Preço (em Sats)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Localização</string>
|
||||
<string name="classifieds_location_placeholder">Cidade, Estado, País</string>
|
||||
<string name="classifieds_condition_new">Novo</string>
|
||||
<string name="classifieds_condition_new_explainer">É uma unidade totalmente nova, na caixa original</string>
|
||||
<string name="classifieds_condition_like_new">Como Novo</string>
|
||||
<string name="classifieds_condition_like_new_explainer">Foi usado, mas não há sinais de uso</string>
|
||||
<string name="classifieds_condition_good">Bom</string>
|
||||
<string name="classifieds_condition_good_explainer">Possui algumas marcas superficiais de uso</string>
|
||||
<string name="classifieds_condition_fair">Regular</string>
|
||||
<string name="classifieds_condition_fair_explainer">Ainda está em estado aceitável e funcional</string>
|
||||
<string name="classifieds_category_clothing">Roupas</string>
|
||||
<string name="classifieds_category_accessories">Acessórios</string>
|
||||
<string name="classifieds_category_electronics">Eletrônicos</string>
|
||||
<string name="classifieds_category_furniture">Móveis</string>
|
||||
<string name="classifieds_category_collectibles">Colecionáveis</string>
|
||||
<string name="classifieds_category_books">Livros</string>
|
||||
<string name="classifieds_category_pets">Animais de Estimação</string>
|
||||
<string name="classifieds_category_sports">Esportes</string>
|
||||
<string name="classifieds_category_fitness">Fitness</string>
|
||||
<string name="classifieds_category_art">Arte</string>
|
||||
<string name="classifieds_category_crafts">Artesanato</string>
|
||||
<string name="classifieds_category_home">Casa</string>
|
||||
<string name="classifieds_category_office">Escritório</string>
|
||||
<string name="classifieds_category_food">Alimentos</string>
|
||||
<string name="classifieds_category_misc">Diversos</string>
|
||||
<string name="classifieds_category_other">Outros</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
<?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">Покажите на КР код</string>
|
||||
<string name="show_qr">Прикажи КР</string>
|
||||
</resources>
|
||||
|
||||
@@ -404,6 +404,9 @@
|
||||
<string name="payment">Betalning</string>
|
||||
<string name="cashu">Cashu Token</string>
|
||||
<string name="cashu_redeem">Inlösen</string>
|
||||
<string name="cashu_redeem_to_zap">Skicka till Zap Plånbok</string>
|
||||
<string name="cashu_redeem_to_cashu">Öppna i Cashu Plånbok</string>
|
||||
<string name="cashu_copy_token">Kopiera Token</string>
|
||||
<string name="no_lightning_address_set">Ingen Lightning-adress angiven</string>
|
||||
<string name="copied_token_to_clipboard">Kopierade token till urklipp</string>
|
||||
<string name="live_stream_live_tag">LIVE</string>
|
||||
@@ -415,6 +418,7 @@
|
||||
<string name="are_you_sure_you_want_to_log_out">Att logga ut raderar all din lokala information. Se till att ha dina privata nycklar säkerhetskopierade för att undvika att förlora ditt konto. Vill du fortsätta?</string>
|
||||
<string name="followed_tags">Följda taggar</string>
|
||||
<string name="relay_setup">Reläer</string>
|
||||
<string name="discover_marketplace">Marknadsplats</string>
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Gemenskap</string>
|
||||
<string name="discover_chat">Chattar</string>
|
||||
@@ -473,6 +477,7 @@
|
||||
<string name="automatically_play_videos_description">Spela upp videor och GIFs automatiskt</string>
|
||||
<string name="automatically_show_url_preview_description">Visa förhandsgranskning av URL</string>
|
||||
<string name="load_image_description">När bilder ska laddas</string>
|
||||
<string name="copy_to_clipboard">Kopiera till Urklipp</string>
|
||||
<string name="copy_url_to_clipboard">Kopiera URL till urklipp</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopiera anteckningens ID till urklipp</string>
|
||||
<string name="created_at">Skapad den</string>
|
||||
@@ -496,8 +501,8 @@
|
||||
<string name="active_for_search">Sök</string>
|
||||
<string name="zap_split_title">Dela och vidarebefordra Zaps</string>
|
||||
<string name="zap_split_explainer">Stödjande klienter kommer att dela och vidarebefordra zaps till användarna som har lagts till här istället för dig</string>
|
||||
<string name="zap_split_serarch_and_add_user">Sök och lägg till användare</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Användarnamn eller visningsnamn</string>
|
||||
<string name="zap_split_search_and_add_user">Sök och lägg till användare</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Användarnamn eller visningsnamn</string>
|
||||
<string name="missing_lud16">Saknad lightning-konfiguration</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren %1$s har inte ställt in en blixtfakturaadress för att ta emot satoshis</string>
|
||||
<string name="zap_split_weight">Procent</string>
|
||||
@@ -526,8 +531,9 @@
|
||||
<string name="cashu_failed_redemption">Kunde inte lösa in Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint gav följande felmeddelande: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Cashu-token har redan spenderats.</string>
|
||||
<string name="cashu_sucessful_redemption">Cashu mottagen</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sats skickades till din plånbok. (Avgifter: %2$s sats)</string>
|
||||
<string name="cashu_successful_redemption">Cashu mottagen</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats skickades till din plånbok. (Avgifter: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">Ingen kompatibel Cashu-plånbok hittades på systemet</string>
|
||||
<string name="error_unable_to_fetch_invoice">Kunde inte hämta fakturan från mottagarens servrar</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Din Wallet Connect-leverantör returnerade följande fel: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Kunde inte ansluta till Tor</string>
|
||||
@@ -555,4 +561,41 @@
|
||||
Efter installationen väljer du den app du vill använda i inställningarna.
|
||||
</string>
|
||||
<string name="payment_required_title">Meddelande från %1$s</string>
|
||||
<string name="thread_title">Tråd</string>
|
||||
<string name="send_the_seller_a_message">Skicka ett meddelande till säljaren</string>
|
||||
<string name="hi_seller_is_this_still_available">Hej %1$s, är detta fortfarande tillgängligt?</string>
|
||||
<string name="hi_there_is_this_still_available">Hallå där, är detta fortfarande tillgängligt?</string>
|
||||
<string name="classifieds">Sälj en artikel</string>
|
||||
<string name="classifieds_title">Titel</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">Skick</string>
|
||||
<string name="classifieds_category">Kategori</string>
|
||||
<string name="classifieds_price">Pris (i Sats)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Plats</string>
|
||||
<string name="classifieds_location_placeholder">Stad, Region, Land</string>
|
||||
<string name="classifieds_condition_new">Ny</string>
|
||||
<string name="classifieds_condition_new_explainer">Det är en helt ny enhet, i originalförpackningen</string>
|
||||
<string name="classifieds_condition_like_new">Som ny</string>
|
||||
<string name="classifieds_condition_like_new_explainer">Den är använd, men det finns inga tecken på användning</string>
|
||||
<string name="classifieds_condition_good">Bra</string>
|
||||
<string name="classifieds_condition_good_explainer">Den har några ytliga användningsmärken</string>
|
||||
<string name="classifieds_condition_fair">Acceptabel</string>
|
||||
<string name="classifieds_condition_fair_explainer">Den är fortfarande i acceptabelt och funktionellt skick</string>
|
||||
<string name="classifieds_category_clothing">Kläder</string>
|
||||
<string name="classifieds_category_accessories">Tillbehör</string>
|
||||
<string name="classifieds_category_electronics">Elektronik</string>
|
||||
<string name="classifieds_category_furniture">Möbler</string>
|
||||
<string name="classifieds_category_collectibles">Samlarobjekt</string>
|
||||
<string name="classifieds_category_books">Böcker</string>
|
||||
<string name="classifieds_category_pets">Husdjur</string>
|
||||
<string name="classifieds_category_sports">Sport</string>
|
||||
<string name="classifieds_category_fitness">Träning</string>
|
||||
<string name="classifieds_category_art">Konst</string>
|
||||
<string name="classifieds_category_crafts">Hantverk</string>
|
||||
<string name="classifieds_category_home">Hem</string>
|
||||
<string name="classifieds_category_office">Kontor</string>
|
||||
<string name="classifieds_category_food">Mat</string>
|
||||
<string name="classifieds_category_misc">Diverse</string>
|
||||
<string name="classifieds_category_other">Annat</string>
|
||||
</resources>
|
||||
|
||||
@@ -492,8 +492,8 @@
|
||||
<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="zap_split_search_and_add_user">搜索并添加用户</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">用户名或显示名</string>
|
||||
<string name="missing_lud16">打闪设置缺失</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">用户 %1$s 尚未设置接收聪的闪电地址</string>
|
||||
<string name="zap_split_weight">百分比</string>
|
||||
@@ -517,8 +517,8 @@
|
||||
<string name="error_parsing_nip47_title">无法设置钱包连接</string>
|
||||
<string name="cashu_failed_redemption">无法兑换Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Cashu 令牌已经消耗。</string>
|
||||
<string name="cashu_sucessful_redemption">已收到 Cashu</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s 聪 已被发送到您的钱包。(手续费︰ %2$s 聪)</string>
|
||||
<string name="cashu_successful_redemption">已收到 Cashu</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s 聪 已被发送到您的钱包。(手续费︰ %2$s 聪)</string>
|
||||
<string name="error_unable_to_fetch_invoice">无法从接收者的服务器获取发票</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">您的钱包连接提供者返回了以下错误: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">无法连接到 Tor</string>
|
||||
|
||||
@@ -487,8 +487,8 @@
|
||||
<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="zap_split_search_and_add_user">搜索並添加用戶</string>
|
||||
<string name="zap_split_search_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">25</string>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<string name="profile_image">Profile Image</string>
|
||||
<string name="scan_qr">Scan QR</string>
|
||||
<string name="show_anyway">Show Anyway</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Post was reported by</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Post was muted or reported by</string>
|
||||
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
|
||||
<string name="channel_image">Channel Image</string>
|
||||
<string name="referenced_event_not_found">Referenced event not found</string>
|
||||
@@ -470,6 +470,10 @@
|
||||
<string name="payment">Payment</string>
|
||||
<string name="cashu">Cashu Token</string>
|
||||
<string name="cashu_redeem">Redeem</string>
|
||||
<string name="cashu_redeem_to_zap">Send to Zap Wallet</string>
|
||||
<string name="cashu_redeem_to_cashu">Open in Cashu Wallet</string>
|
||||
<string name="cashu_copy_token">Copy Token</string>
|
||||
|
||||
<string name="no_lightning_address_set">No Lightning Address set</string>
|
||||
<string name="copied_token_to_clipboard">Copied token to clipboard</string>
|
||||
|
||||
@@ -485,6 +489,7 @@
|
||||
|
||||
<string name="relay_setup">Relays</string>
|
||||
|
||||
<string name="discover_marketplace">Marketplace</string>
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Community</string>
|
||||
<string name="discover_chat">Chats</string>
|
||||
@@ -558,6 +563,7 @@
|
||||
<string name="automatically_show_url_preview_description">Show URL previews</string>
|
||||
<string name="load_image_description">When to load images</string>
|
||||
|
||||
<string name="copy_to_clipboard">Copy to clipboard</string>
|
||||
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
|
||||
|
||||
@@ -589,8 +595,8 @@
|
||||
|
||||
<string name="zap_split_title">Split and Forward Zaps</string>
|
||||
<string name="zap_split_explainer">Supporting clients will split and forward zaps to the users added here instead of yours</string>
|
||||
<string name="zap_split_serarch_and_add_user">Search and Add User</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Username or display name</string>
|
||||
<string name="zap_split_search_and_add_user">Search and Add User</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Username or display name</string>
|
||||
<string name="missing_lud16">Missing lightning setup</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">User %1$s does not have a lightning address set up to receive sats</string>
|
||||
<string name="zap_split_weight">Percentage</string>
|
||||
@@ -627,8 +633,9 @@
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint provided the following error message: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Cashu tokens already spent.</string>
|
||||
|
||||
<string name="cashu_sucessful_redemption">Cashu Received</string>
|
||||
<string name="cashu_sucessful_redemption_explainer">%1$s sats were sent to your wallet. (Fees: %2$s sats)</string>
|
||||
<string name="cashu_successful_redemption">Cashu Received</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats were sent to your wallet. (Fees: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">No compatible Cashu wallet found on system</string>
|
||||
|
||||
<string name="error_unable_to_fetch_invoice">Unable to fetch invoice from receiver\'s servers</string>
|
||||
|
||||
@@ -667,4 +674,44 @@
|
||||
<string name="send_the_seller_a_message">Send the seller a message</string>
|
||||
<string name="hi_seller_is_this_still_available">Hi %1$s, is this still available?</string>
|
||||
<string name="hi_there_is_this_still_available">Hi there, is this still available?</string>
|
||||
|
||||
<string name="classifieds">Sell an Item</string>
|
||||
|
||||
<string name="classifieds_title">Title</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">Condition</string>
|
||||
<string name="classifieds_category">Category</string>
|
||||
<string name="classifieds_price">Price (in Sats)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Location</string>
|
||||
<string name="classifieds_location_placeholder">City, State, Country</string>
|
||||
|
||||
<string name="classifieds_condition_new">New</string>
|
||||
<string name="classifieds_condition_new_explainer">It\'s a brand new unit, in the original box</string>
|
||||
|
||||
<string name="classifieds_condition_like_new">Like New</string>
|
||||
<string name="classifieds_condition_like_new_explainer">It\'s used, but there are no signs of usage</string>
|
||||
|
||||
<string name="classifieds_condition_good">Good</string>
|
||||
<string name="classifieds_condition_good_explainer">It has some superficial usage marks</string>
|
||||
|
||||
<string name="classifieds_condition_fair">Fair</string>
|
||||
<string name="classifieds_condition_fair_explainer">It is still in acceptable and functional shape</string>
|
||||
|
||||
<string name="classifieds_category_clothing">Clothing</string>
|
||||
<string name="classifieds_category_accessories">Accessories</string>
|
||||
<string name="classifieds_category_electronics">Electronics</string>
|
||||
<string name="classifieds_category_furniture">Furniture</string>
|
||||
<string name="classifieds_category_collectibles">Collectibles</string>
|
||||
<string name="classifieds_category_books">Books</string>
|
||||
<string name="classifieds_category_pets">Pets</string>
|
||||
<string name="classifieds_category_sports">Sports</string>
|
||||
<string name="classifieds_category_fitness">Fitness</string>
|
||||
<string name="classifieds_category_art">Art</string>
|
||||
<string name="classifieds_category_crafts">Crafts</string>
|
||||
<string name="classifieds_category_home">Home</string>
|
||||
<string name="classifieds_category_office">Office</string>
|
||||
<string name="classifieds_category_food">Food</string>
|
||||
<string name="classifieds_category_misc">Miscellaneous</string>
|
||||
<string name="classifieds_category_other">Other</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class Nip96Test {
|
||||
|
||||
val json = """
|
||||
{
|
||||
"api_url": "https://nostr.build/api/v2/nip96/upload",
|
||||
"download_url": "https://media.nostr.build",
|
||||
"supported_nips": [
|
||||
94,
|
||||
96,
|
||||
98
|
||||
],
|
||||
"tos_url": "https://nostr.build/tos/",
|
||||
"content_types": [
|
||||
"image/*",
|
||||
"video/*",
|
||||
"audio/*"
|
||||
],
|
||||
"plans": {
|
||||
"free": {
|
||||
"name": "Free",
|
||||
"is_nip98_required": true,
|
||||
"url": "https://nostr.build",
|
||||
"max_byte_size": 26214400,
|
||||
"file_expiration": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"media_transformations": {
|
||||
"image": [
|
||||
"resizing",
|
||||
"format_conversion",
|
||||
"compression",
|
||||
"metadata_stripping"
|
||||
],
|
||||
"video": [
|
||||
"resizing",
|
||||
"format_conversion",
|
||||
"compression"
|
||||
]
|
||||
}
|
||||
},
|
||||
"professional": {
|
||||
"name": "Professional",
|
||||
"is_nip98_required": true,
|
||||
"url": "https://nostr.build/signup/new/",
|
||||
"max_byte_size": 10737418240,
|
||||
"file_expiration": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"media_transformations": {
|
||||
"image": [
|
||||
"resizing",
|
||||
"format_conversion",
|
||||
"compression",
|
||||
"metadata_stripping"
|
||||
],
|
||||
"video": [
|
||||
"resizing",
|
||||
"format_conversion",
|
||||
"compression"
|
||||
]
|
||||
}
|
||||
},
|
||||
"creator": {
|
||||
"name": "Creator",
|
||||
"is_nip98_required": true,
|
||||
"url": "https://nostr.build/signup/new/",
|
||||
"max_byte_size": 26843545600,
|
||||
"file_expiration": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"media_transformations": {
|
||||
"image": [
|
||||
"resizing",
|
||||
"format_conversion",
|
||||
"compression",
|
||||
"metadata_stripping"
|
||||
],
|
||||
"video": [
|
||||
"resizing",
|
||||
"format_conversion",
|
||||
"compression"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@Test()
|
||||
fun parseNostrBuild() {
|
||||
val info = Nip96Retriever().parse(json)
|
||||
|
||||
assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl)
|
||||
assertEquals("https://media.nostr.build", info.downloadUrl)
|
||||
assertEquals(listOf(94, 96, 98), info.supportedNips)
|
||||
assertEquals("https://nostr.build/tos/", info.tosUrl)
|
||||
assertEquals(listOf("image/*", "video/*", "audio/*"), info.contentTypes)
|
||||
|
||||
assertEquals(listOf("creator", "free", "professional"), info.plans.keys.sorted())
|
||||
|
||||
assertEquals("Free", info.plans["free"]?.name)
|
||||
assertEquals(true, info.plans["free"]?.isNip98Required)
|
||||
assertEquals("https://nostr.build", info.plans["free"]?.url)
|
||||
assertEquals(26214400L, info.plans["free"]?.maxByteSize)
|
||||
assertEquals(listOf(0, 0), info.plans["free"]?.fileExpiration)
|
||||
assertEquals(listOf("image", "video"), info.plans["free"]?.mediaTransformations?.keys?.sorted())
|
||||
|
||||
assertEquals(26843545600L, info.plans["creator"]?.maxByteSize)
|
||||
assertEquals(10737418240L, info.plans["professional"]?.maxByteSize)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ dependencies {
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.2.1'
|
||||
androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.2.2'
|
||||
androidTestImplementation project(path: ':quartz')
|
||||
|
||||
// Add your dependencies here. Note that you cannot benchmark code
|
||||
|
||||
+4
-4
@@ -7,9 +7,9 @@ buildscript {
|
||||
compose_ui_version = '1.5.4'
|
||||
nav_version = '2.7.5'
|
||||
room_version = "2.4.3"
|
||||
accompanist_version = '0.30.1'
|
||||
accompanist_version = '0.32.0'
|
||||
coil_version = '2.5.0'
|
||||
vico_version = '1.13.0'
|
||||
vico_version = '1.13.1'
|
||||
media3_version = '1.2.0'
|
||||
core_ktx_version = '1.12.0'
|
||||
material3_version = '1.1.2'
|
||||
@@ -20,8 +20,8 @@ buildscript {
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'com.android.application' version '8.1.4' apply false
|
||||
id 'com.android.library' version '8.1.4' apply false
|
||||
id 'com.android.application' version '8.2.0' apply false
|
||||
id 'com.android.library' version '8.2.0' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.9.10' apply false
|
||||
id 'org.jetbrains.kotlin.jvm' version '1.9.10' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10' apply false
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#Wed Jan 04 09:23:50 EST 2023
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ dependencies {
|
||||
|
||||
// LibSodium for XChaCha encryption
|
||||
implementation "com.goterl:lazysodium-android:5.1.0@aar"
|
||||
implementation 'net.java.dev.jna:jna:5.13.0@aar'
|
||||
implementation 'net.java.dev.jna:jna:5.14.0@aar'
|
||||
|
||||
api 'com.fasterxml.jackson.module:jackson-module-kotlin:2.16.0'
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class ChannelMessageEvent(
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null,
|
||||
onReady: (ChannelMessageEvent) -> Unit
|
||||
) {
|
||||
val tags = mutableListOf(
|
||||
@@ -60,6 +61,11 @@ class ChannelMessageEvent(
|
||||
geohash?.let {
|
||||
tags.addAll(geohashMipMap(it))
|
||||
}
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
//tags.add(arrayOf("nip94", it.toJson()))
|
||||
}
|
||||
}
|
||||
|
||||
signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class ClassifiedsEvent(
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1)
|
||||
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
|
||||
fun condition() = tags.firstOrNull { it.size > 1 && it[0] == "condition" }?.get(1)
|
||||
fun images() = tags.filter { it.size > 1 && it[0] == "image" }.map { it[1] }
|
||||
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
|
||||
fun price() = tags.firstOrNull { it.size > 1 && it[0] == "price" }?.let {
|
||||
@@ -32,23 +33,66 @@ class ClassifiedsEvent(
|
||||
null
|
||||
}
|
||||
|
||||
enum class CONDITION(val value: String){
|
||||
NEW("new"),
|
||||
USED_LIKE_NEW("like new"),
|
||||
USED_GOOD("good"),
|
||||
USED_FAIR("fair"),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 30402
|
||||
private val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
|
||||
|
||||
fun create(
|
||||
dTag: String,
|
||||
title: String?,
|
||||
image: String?,
|
||||
summary: String?,
|
||||
message: String,
|
||||
price: Price?,
|
||||
location: String?,
|
||||
publishedAt: Long?,
|
||||
category: String?,
|
||||
condition: ClassifiedsEvent.CONDITION?,
|
||||
publishedAt: Long? = TimeUtils.now(),
|
||||
replyTos: List<String>?,
|
||||
addresses: List<ATag>?,
|
||||
mentions: List<String>?,
|
||||
directMentions: Set<HexKey>,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
onReady: (ClassifiedsEvent) -> Unit
|
||||
) {
|
||||
val tags = mutableListOf<Array<String>>()
|
||||
|
||||
replyTos?.forEach {
|
||||
if (it in directMentions) {
|
||||
tags.add(arrayOf("e", it, "", "mention"))
|
||||
} else {
|
||||
tags.add(arrayOf("e", it))
|
||||
}
|
||||
}
|
||||
mentions?.forEach {
|
||||
if (it in directMentions) {
|
||||
tags.add(arrayOf("p", it, "", "mention"))
|
||||
} else {
|
||||
tags.add(arrayOf("p", it))
|
||||
}
|
||||
}
|
||||
addresses?.forEach {
|
||||
val aTag = it.toTag()
|
||||
if (aTag in directMentions) {
|
||||
tags.add(arrayOf("a", aTag, "", "mention"))
|
||||
} else {
|
||||
tags.add(arrayOf("a", aTag))
|
||||
}
|
||||
}
|
||||
|
||||
tags.add(arrayOf("d", dTag))
|
||||
title?.let { tags.add(arrayOf("title", it)) }
|
||||
image?.let { tags.add(arrayOf("image", it)) }
|
||||
@@ -62,11 +106,47 @@ class ClassifiedsEvent(
|
||||
tags.add(arrayOf("price", it.amount))
|
||||
}
|
||||
}
|
||||
category?.let { tags.add(arrayOf("t", it)) }
|
||||
location?.let { tags.add(arrayOf("location", it)) }
|
||||
publishedAt?.let { tags.add(arrayOf("publishedAt", it.toString())) }
|
||||
title?.let { tags.add(arrayOf("title", it)) }
|
||||
condition?.let { tags.add(arrayOf("condition", it.value)) }
|
||||
|
||||
signer.sign(createdAt, kind, tags.toTypedArray(), "", onReady)
|
||||
findHashtags(message).forEach {
|
||||
tags.add(arrayOf("t", it))
|
||||
tags.add(arrayOf("t", it.lowercase()))
|
||||
}
|
||||
zapReceiver?.forEach {
|
||||
tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
|
||||
}
|
||||
findURLs(message).forEach {
|
||||
val removedParamsFromUrl = if (it.contains("?"))
|
||||
it.split("?")[0].lowercase()
|
||||
else if (it.contains("#"))
|
||||
it.split("#")[0].lowercase()
|
||||
else
|
||||
it
|
||||
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
tags.add(arrayOf("image", it))
|
||||
}
|
||||
tags.add(arrayOf("r", it))
|
||||
}
|
||||
if (markAsSensitive) {
|
||||
tags.add(arrayOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(arrayOf("zapraiser", "$it"))
|
||||
}
|
||||
geohash?.let {
|
||||
tags.addAll(geohashMipMap(it))
|
||||
}
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
//tags.add(arrayOf("nip94", it.toJson()))
|
||||
}
|
||||
}
|
||||
|
||||
signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,9 @@ open class Event(
|
||||
|
||||
override fun toJson(): String = mapper.writeValueAsString(toJsonObject())
|
||||
|
||||
override fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
|
||||
override fun hasAnyTaggedUser() = hasTagWithContent("p")
|
||||
|
||||
override fun hasTagWithContent(tagName: String) = tags.any { it.size > 1 && it[0] == tagName }
|
||||
|
||||
override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
@@ -51,6 +51,7 @@ class EventFactory {
|
||||
SealedGossipEvent.kind -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileServersEvent.kind -> FileServersEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
@@ -68,6 +68,7 @@ interface EventInterface {
|
||||
fun zapraiserAmount(): Long?
|
||||
|
||||
fun hasAnyTaggedUser(): Boolean
|
||||
fun hasTagWithContent(tagName: String): Boolean
|
||||
|
||||
fun taggedAddresses(): List<ATag>
|
||||
fun taggedUsers(): List<HexKey>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user