Compare commits

..
100 Commits
Author SHA1 Message Date
Vitor Pamplona a14d4e6f4e v0.82.0 2023-12-12 09:32:59 -05:00
Vitor Pamplona de61c0350e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-12 09:31:25 -05:00
Vitor Pamplona 3e3c97076d Formatting 2023-12-12 09:31:14 -05:00
Vitor Pamplona f62c986016 Improving sizes of blurhashes 2023-12-12 08:50:11 -05:00
Vitor PamplonaandGitHub 04108e893d Merge pull request #720 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-12 08:44:58 -05:00
Crowdin Bot 042394796a New Crowdin translations by GitHub Action 2023-12-12 13:44:16 +00:00
Vitor Pamplona bfce57f686 Improving the video switching from blurhash to video 2023-12-11 19:58:29 -05:00
Vitor Pamplona 5bb983ba5a Adding blurhash when images are not supposed to be downloaded. 2023-12-11 17:57:19 -05:00
Vitor Pamplona debae02421 Improves the rendering of inline metadata 2023-12-11 16:22:01 -05:00
Vitor Pamplona 6fe93fdf33 Reverting image comment 2023-12-11 14:26:28 -05:00
Vitor Pamplona 3d225423ef Adds support for nip54 2023-12-11 12:51:53 -05:00
Vitor Pamplona 49e1da8812 Spacing 2023-12-11 12:50:44 -05:00
Vitor Pamplona 04addc4cba Adds support for the FileServers kind 2023-12-10 21:27:47 -05:00
Vitor Pamplona c5cf2c5442 Adding the delete option for NIP-96 2023-12-10 21:21:00 -05:00
Vitor Pamplona ddb7e6b7f2 Updates JNA 2023-12-10 18:32:47 -05:00
Vitor Pamplona e5af34efca Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-08 22:08:30 -05:00
Vitor Pamplona b3bdbbed98 - Migrates the old imageservers to NIP-96 for the main feed, chats, private messages, stories and user metadata uploads.
- Fixes hash calculation of the entire payload.
- Unifies uploads into NIP-94 images.
2023-12-08 22:08:19 -05:00
Vitor PamplonaandGitHub 656b066d3a Merge pull request #719 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-08 19:50:21 -05:00
Crowdin Bot 4248fadc69 New Crowdin translations by GitHub Action 2023-12-09 00:45:17 +00:00
Vitor PamplonaandGitHub 18281cbc49 Merge pull request #718 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-08 19:44:05 -05:00
Crowdin Bot 1d67af328a New Crowdin translations by GitHub Action 2023-12-08 16:23:27 +00:00
Vitor PamplonaandGitHub 72b4f6acef Merge pull request #715 from davotoula/tr
Correct spelling of localised string reference
2023-12-08 11:21:52 -05:00
Vitor PamplonaandGitHub 26273270ba Merge branch 'main' into tr 2023-12-08 11:21:46 -05:00
Vitor Pamplona db3824c3c7 New Cashu Redeeming card. 2023-12-06 18:43:54 -05:00
Vitor Pamplona d6de1d03d8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-06 15:40:05 -05:00
Vitor Pamplona 8e4b7c66b5 Adds a UserAgent to all HTTP requests. 2023-12-06 15:40:01 -05:00
Vitor Pamplona 45c750069a Refactoring the update of channel information. 2023-12-06 15:38:31 -05:00
Vitor PamplonaandGitHub a411a72024 Merge pull request #716 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-06 14:59:12 -05:00
Crowdin Bot 219d163680 New Crowdin translations by GitHub Action 2023-12-06 19:38:31 +00:00
Vitor Pamplona a4c9f42205 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-06 14:36:21 -05:00
Vitor Pamplona a8936c54d8 Adds a Marketplace tab to Discovery 2023-12-06 13:40:54 -05:00
David Kaspar 74875bb564 Correct spelling of localised string reference 2023-12-06 15:07:52 +01:00
Vitor PamplonaandGitHub 61d412d941 Merge pull request #714 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-06 08:14:16 -05:00
Crowdin Bot 5bb1e47edf New Crowdin translations by GitHub Action 2023-12-06 13:13:27 +00:00
Vitor PamplonaandGitHub b023614b5f Merge pull request #713 from davotoula/tr
Updated translations for cs/de/sv/pt
2023-12-06 08:11:39 -05:00
David Kaspar 7245718003 Updated translations for cs/de/sv/pt 2023-12-06 12:15:02 +01:00
David Kaspar ba1f98d930 Updated translations for cs/de/sv/pt 2023-12-06 12:08:20 +01:00
David Kaspar ceeabffcb7 Correct spelling of localised string reference 2023-12-06 12:04:50 +01:00
David Kaspar 2a943493b3 Updated translations for cs/de/sv/pt 2023-12-06 11:21:41 +01:00
Vitor Pamplona 332a2f41b6 Moves the relay observer and reconnection to Service Manager's scope. 2023-12-05 21:48:43 -05:00
Vitor Pamplona ce2cfb7c86 fixes bug when updating relay list that used keep the previous list. 2023-12-05 21:48:05 -05:00
Vitor Pamplona 41ef1abf0e Fixes deletion of replaceable events. 2023-12-05 21:47:20 -05:00
Vitor Pamplona fce27c3c55 Presenting better error messages when the image upload fails. 2023-12-05 21:46:35 -05:00
Vitor Pamplona 6662966ab3 Fixing crash when image is an SVG and tries to compress. 2023-12-05 21:45:56 -05:00
Vitor Pamplona f458f00edc 1. Adds Classified creation from Amethyst.
2. Adds relay information for Replaceable events.
2023-12-05 21:44:49 -05:00
Vitor PamplonaandGitHub 8f1fbe10e9 Merge pull request #712 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-05 11:41:45 -05:00
Crowdin Bot 69efa78fbc New Crowdin translations by GitHub Action 2023-12-05 16:16:54 +00:00
Vitor Pamplona 64a07d8760 Updates to fix Crash due to malformed video formats. 2023-12-05 09:52:15 -05:00
Vitor Pamplona cb672d92d0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-04 13:25:54 -05:00
Vitor Pamplona 95bcbfdded Reusing common icons for Cashu's Redemption 2023-12-04 13:23:19 -05:00
Vitor PamplonaandGitHub ac269727bc Merge pull request #711 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-04 11:57:45 -05:00
Crowdin Bot b1a7d18c0a New Crowdin translations by GitHub Action 2023-12-04 15:53:21 +00:00
Vitor Pamplona 619b59357b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-04 10:52:23 -05:00
Vitor PamplonaandGitHub 86dcc3ccd9 Merge pull request #710 from believethehype/main
add a button to cashu preview to redeem on external wallet
2023-12-04 10:51:41 -05:00
Vitor Pamplona 6772360ae7 Fixes bottom bar appearing in chats when the keyboard is open. 2023-12-04 10:48:29 -05:00
Vitor Pamplona 05cbbd79cb Uses nostr.wine instead of filter.nostr.wine as a search relay. 2023-12-04 10:47:38 -05:00
Believethehype a80c03a2d2 Update CashuRedeem.kt 2023-12-04 16:20:40 +01:00
Believethehype 6c3abe17ed add a button to cashu preview to redeem on external wallet, additional to melting to ln 2023-12-03 15:02:22 +01:00
Vitor Pamplona 7cd09261de Optimizes the rendering of the side bar. 2023-12-02 17:23:15 -05:00
Vitor Pamplona 6ef1bee8a0 Updates EOSE in the same thread of the new event to reduce the amount of coroutine launches. 2023-12-02 14:54:34 -05:00
Vitor Pamplona 66b8b04db3 Updates dependencies. 2023-12-02 14:14:15 -05:00
Vitor Pamplona 3b06abb2bc Upgrades junit benchmark 2023-12-02 14:12:10 -05:00
Vitor Pamplona 138fafa204 Updates gradle 2023-12-02 14:11:19 -05:00
Vitor Pamplona 9cbdbb8592 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-02 13:56:53 -05:00
Vitor Pamplona 1c0ec93cbb Testing the use of just one HttpClient for the entire app. 2023-12-02 13:55:55 -05:00
Vitor PamplonaandGitHub c6fa2b5f80 Merge pull request #708 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-02 13:46:20 -05:00
Crowdin Bot dd55ad18e3 New Crowdin translations by GitHub Action 2023-12-02 18:39:06 +00:00
Vitor PamplonaandGitHub 284da365a1 Merge pull request #707 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-02 13:37:59 -05:00
Crowdin Bot 7a06c2e9c4 New Crowdin translations by GitHub Action 2023-12-02 16:21:46 +00:00
Vitor Pamplona 61449774a1 Improves the Redeeming feedback
Improves fee calculation from the mint
2023-12-02 11:18:01 -05:00
Vitor PamplonaandGitHub 81a3baa41c Merge pull request #706 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-01 09:35:18 -05:00
Crowdin Bot 4b7e58dd1c New Crowdin translations by GitHub Action 2023-12-01 14:06:41 +00:00
Vitor PamplonaandGitHub 14ce42401b Merge pull request #705 from greenart7c3/main
fix zap splits when using amber with intents
2023-12-01 09:05:26 -05:00
greenart7c3 ecb8344287 fix zap splits when using amber with intents 2023-12-01 10:52:15 -03:00
Vitor Pamplona 9bc5549d76 v0.81.5 2023-11-30 17:28:58 -05:00
Vitor Pamplona 8ac8894ccd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-30 17:27:03 -05:00
Vitor Pamplona efb9814f1b Fixes blocked lists on Videos. 2023-11-30 17:18:41 -05:00
Vitor PamplonaandGitHub 3582f587b3 Merge pull request #704 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-30 17:05:19 -05:00
Crowdin Bot 4fbc9f6d53 New Crowdin translations by GitHub Action 2023-11-30 21:26:37 +00:00
Vitor Pamplona e9ec1c45a0 v0.81.4 2023-11-30 16:25:07 -05:00
Vitor Pamplona b2b7cba352 Loads basic information for all the accounts in the app. 2023-11-30 16:24:53 -05:00
Vitor Pamplona 393fbd0359 clean up 2023-11-30 15:17:21 -05:00
Vitor Pamplona 47ae42e3fe Avoiding bug when creating accounts in the background and trying to create the live set 2023-11-30 15:16:21 -05:00
Vitor Pamplona 5db3ede09e Makes Client thread safe. Forcing the disconnect of an old relay list before connecting to a new one. 2023-11-30 15:03:58 -05:00
Vitor Pamplona eca420d789 Fixing the scope in HomeDataSource. 2023-11-30 15:02:16 -05:00
Vitor Pamplona 5347373cde Avoids sending filters with empty lists. 2023-11-30 14:53:59 -05:00
Vitor Pamplona 05596cb9bf Forces websocket closure onFailure 2023-11-30 14:51:55 -05:00
Vitor Pamplona 3d1310295b No need to call two launch functions 2023-11-30 14:51:13 -05:00
Vitor Pamplona 35c780f81b Avoids bug on creating the live set in the IO thread. 2023-11-30 14:50:24 -05:00
Vitor Pamplona b021920eaa Emitting an empty list if it cannot decrypt it 2023-11-30 11:22:42 -05:00
Vitor Pamplona 2b27ac3aec Cache zap calculations in notification cards. 2023-11-30 11:22:06 -05:00
Vitor Pamplona c692c9125b Fixes null list names showing before the list is loaded 2023-11-30 10:33:48 -05:00
Vitor Pamplona 7322fd7aae Fixes hidden buttons in the Chat creation screen. 2023-11-30 10:30:12 -05:00
Vitor Pamplona 07a92cd70d Fixes all or nothing decryption of Zaps failing in a few cases. Now it has a fallback when decryption fails. 2023-11-30 10:15:36 -05:00
Vitor Pamplona 21a797be9d v0.81.3 2023-11-29 17:34:15 -05:00
Vitor Pamplona fe49cbabce Improves the rendering of LN Invoice Previews. 2023-11-29 17:22:04 -05:00
Vitor Pamplona 9f674a0088 Adds flexibility on timeouts between WIFI and mobile connections. 2023-11-29 17:21:40 -05:00
Vitor Pamplona b44e94cb16 Adds a test case for urls ending with a period. 2023-11-29 16:50:36 -05:00
Vitor Pamplona a8da645be3 Increasing timeout to account for slower connections. 2023-11-29 16:50:11 -05:00
Vitor Pamplona c6e6bf6032 Loads the user profile before building the live set. 2023-11-29 16:14:02 -05:00
112 changed files with 4416 additions and 1486 deletions
+9
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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 = "Thats it! http://vitorpamplona.com/. Thats the note"
val state = RichTextParser().parseText(text, EmptyTagList)
printStateForDebug(state)
val expectedResult = listOf<String>(
"RegularText(Thats)",
"RegularText(it!)",
"Link(http://vitorpamplona.com/.)",
"RegularText(Thats)",
"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)
}
}
}
}
@@ -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()
}
}
@@ -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 += "![]($word) "
} 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,
@@ -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
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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>
+47 -4
View File
@@ -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>
+47 -4
View File
@@ -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>
+4 -4
View File
@@ -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>
+4 -4
View File
@@ -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>
+4 -4
View File
@@ -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>
+4 -4
View File
@@ -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>
+119 -3
View File
@@ -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">(0100)%</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>
+48 -4
View File
@@ -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>
+48 -4
View File
@@ -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"></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>
+4 -4
View File
@@ -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>
+4 -4
View File
@@ -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>
+50 -5
View File
@@ -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>
+47 -4
View File
@@ -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>
+4 -1
View File
@@ -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>
+47 -4
View File
@@ -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>
+4 -4
View File
@@ -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>
+2 -2
View File
@@ -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>
+52 -5
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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