Compare commits

..
171 Commits
Author SHA1 Message Date
Vitor Pamplona fb0748f4a7 v0.77.3 2023-09-20 15:12:23 -04:00
Vitor Pamplona 1ec4823122 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-20 15:11:56 -04:00
Vitor Pamplona cce5e0bf21 v0.77.2 2023-09-20 15:11:51 -04:00
Vitor Pamplona 0006595786 Quick fix on zap notifications 2023-09-20 15:11:35 -04:00
Vitor PamplonaandGitHub 6b884f645e Merge pull request #604 from greenart7c3/main
Change nav bar color according to the theme
2023-09-20 14:58:18 -04:00
greenart7c3 946723a80e change nav bar color 2023-09-20 15:42:21 -03:00
Vitor Pamplona 791debbaca v0.77.1 2023-09-20 14:18:07 -04:00
Vitor Pamplona 3bb5a4e50d Fixing F-droid flavor 2023-09-20 14:01:51 -04:00
Vitor Pamplona 3efa3a68fe Adds communities, hashtags and geohashes to the lists on the top navigation bar. 2023-09-20 12:23:50 -04:00
Vitor Pamplona dc314a94ff Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-20 09:38:00 -04:00
Vitor Pamplona 11b062c41f Moves zap decryption to run in a group, avoiding multiple co-routines per zap 2023-09-20 09:37:56 -04:00
Vitor Pamplona faeb2a3894 Removes the follow unfollow button from the Zap to User Compose 2023-09-20 09:37:23 -04:00
Vitor PamplonaandGitHub b1f45a346d Merge pull request #603 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-20 08:34:26 -04:00
Crowdin Bot 0b16de6a85 New Crowdin translations by GitHub Action 2023-09-20 12:32:50 +00:00
Vitor PamplonaandGitHub 66d4b75373 Merge pull request #602 from greenart7c3/main
Login improvements
2023-09-20 08:31:28 -04:00
greenart7c3 3b376a615c disable buttons if terms not accepted, fix a crash when login with amber 2023-09-20 07:46:31 -03:00
Vitor Pamplona c8da570c89 Fixes IO Calls 2023-09-19 17:44:41 -04:00
Vitor Pamplona 38f4a5b7fc Groups notifications by day first. 2023-09-19 17:32:57 -04:00
Vitor Pamplona b03ff36cf3 Improvements to the rendering of long form content. 2023-09-19 17:01:33 -04:00
Vitor Pamplona d145938652 Fixes blank summaries 2023-09-19 16:51:14 -04:00
Vitor Pamplona 1993388d9f Darker Boost Username 2023-09-19 16:47:30 -04:00
Vitor Pamplona 15e7540b26 Code clean up 2023-09-19 16:43:57 -04:00
Vitor Pamplona c2beaf5f80 Starting a Refactoring of LocalCache away from a Singleton instance. 2023-09-19 15:26:16 -04:00
Vitor Pamplona 5d395419ff Sets up default reactions to be Rocket, Hugs, Watching and Laghuing 2023-09-19 14:58:00 -04:00
Vitor Pamplona f672a51470 Removes old migration from the old preferences. 2023-09-18 17:20:46 -04:00
Vitor Pamplona f741239e23 Updates Google Play services 2023-09-18 17:15:17 -04:00
Vitor Pamplona 3442194b01 Creates Daniel's idea for reposted profile icons 2023-09-18 17:07:17 -04:00
Vitor Pamplona 0b025d7679 Adds a ZapSplit view to MasterNodes in Thread 2023-09-18 16:31:05 -04:00
Vitor Pamplona 227de74317 v0.77.0 2023-09-18 13:36:51 -04:00
Vitor Pamplona 0b9fced8bc Quick refactoring to abstract the name Amber for any external signers. 2023-09-18 13:36:36 -04:00
Vitor Pamplona 274e4f7498 Fixes merge issue on NIP24 TextNotes 2023-09-18 11:23:36 -04:00
Vitor PamplonaandGitHub f6496b5e7e Merge pull request #601 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-18 11:18:57 -04:00
Crowdin Bot ee5ff1845f New Crowdin translations by GitHub Action 2023-09-18 15:18:18 +00:00
Vitor PamplonaandGitHub c84e835ce5 Merge pull request #543 from greenart7c3/amber
Add Amber support
2023-09-18 11:16:55 -04:00
Vitor PamplonaandGitHub cdb5be2fe9 Merge branch 'main' into amber 2023-09-18 11:16:47 -04:00
Vitor Pamplona 2d0c356aa5 v0.76.2 2023-09-18 10:53:44 -04:00
Vitor PamplonaandGitHub 0a92c253b8 Merge pull request #600 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-18 10:39:22 -04:00
Crowdin Bot 92347863bc New Crowdin translations by GitHub Action 2023-09-18 14:38:52 +00:00
Vitor Pamplona a146ec5e1a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-18 10:36:21 -04:00
Vitor Pamplona 83be43e94e Removes the ZapSplit display when the weights are zero or were incorrectly created. 2023-09-18 10:36:05 -04:00
greenart7c3 d726b3b5b5 fix createZapRequestFor userhex 2023-09-18 11:27:05 -03:00
Vitor PamplonaandGitHub c845aac8e5 Merge pull request #599 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-17 12:03:08 -04:00
Crowdin Bot 1cdf114fff New Crowdin translations by GitHub Action 2023-09-17 15:38:29 +00:00
Vitor Pamplona 4d1a99d076 Adds NIP24 Kind1 create function 2023-09-17 11:32:35 -04:00
Vitor Pamplona b1debd9879 AnimatedVisibility seems faster than Crossfade 2023-09-17 11:31:24 -04:00
Vitor Pamplona 3843917bd1 Makes sure the account is writeable before signing Auth for notifications 2023-09-16 15:33:42 -04:00
greenart7c3 2fa4281b6f fix sending messages 2023-09-16 09:11:25 -03:00
greenart7c3 c42944e15b removed AmberUtils.signEvent 2023-09-16 08:49:46 -03:00
greenart7c3 9f9f158709 fix zap splits and show message if user uninstall amber 2023-09-16 08:23:56 -03:00
greenart7c3andGitHub 3a753f0d5d Merge branch 'main' into amber 2023-09-16 08:03:54 -03:00
greenart7c3 13c94112d4 fix private zaps 2023-09-16 07:56:14 -03:00
greenart7c3 dd46f12a05 change amber from debug to release 2023-09-15 21:51:01 -03:00
greenart7c3 9a648d7e84 send the logged in npub to amber 2023-09-15 21:35:32 -03:00
greenart7c3 614dbab1fd refactor some duplicated code 2023-09-15 20:51:13 -03:00
greenart7c3 3d80ee1984 fix decryption of dms whe new message is send 2023-09-15 20:46:29 -03:00
Vitor Pamplona 536fb49bc3 0.76.1 2023-09-15 17:15:54 -04:00
Vitor Pamplona edd93141bc No need for the @ when adding users to the Split 2023-09-15 17:13:35 -04:00
Vitor Pamplona 20c2d19a9c Refining the Payment via Intent 2023-09-15 17:08:19 -04:00
Vitor PamplonaandGitHub 17b2fe9cd8 Merge pull request #596 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-15 14:22:41 -04:00
Crowdin Bot 134e7cee0c New Crowdin translations by GitHub Action 2023-09-15 16:59:32 +00:00
Vitor Pamplona 108ddffcc7 formatting 2023-09-15 12:56:50 -04:00
Vitor Pamplona 20e76daff8 Adds zap splits 2023-09-15 12:56:24 -04:00
greenart7c3 6876a7f0d2 zaps in background 2023-09-15 13:53:05 -03:00
greenart7c3andGitHub 87dbf86129 Merge branch 'main' into amber 2023-09-15 13:18:43 -03:00
greenart7c3 175d1ae33b fix zap dialog not closing 2023-09-15 12:58:58 -03:00
greenart7c3 bf9d318209 use content resolvers with amber 2023-09-15 11:56:07 -03:00
Vitor PamplonaandGitHub 7bee44c5ad Merge pull request #595 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-14 14:01:54 -04:00
Crowdin Bot a32e90aed0 New Crowdin translations by GitHub Action 2023-09-14 17:48:43 +00:00
Vitor Pamplona 310db1008e Fixes bug when verifying the checksum of LNURLs (bech32) in uppercase. 2023-09-14 13:46:29 -04:00
greenart7c3 21560d098e fix upload image 2023-09-13 19:15:00 -03:00
greenart7c3 33f7c5c82e deleted intentutils 2023-09-13 18:06:50 -03:00
greenart7c3 52f17e9f46 nwc support with amber 2023-09-13 16:23:56 -03:00
greenart7c3 1e7ffa365b change relay auth to use signevent 2023-09-13 16:08:38 -03:00
greenart7c3 e00fc83e5c fix reaction row and zaps 2023-09-13 15:54:44 -03:00
greenart7c3andGitHub a2fcb3539e Merge branch 'main' into amber 2023-09-13 13:15:45 -03:00
Vitor Pamplona cb69d056b8 Allowing playback to save any position and not only after 5 seconds. 2023-09-13 12:12:47 -04:00
Vitor Pamplona e7f950cd32 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-13 11:45:27 -04:00
Vitor Pamplona 3b2686ffeb Removes potential bug when the list contains an e tag with an a reference 2023-09-13 11:45:17 -04:00
Vitor PamplonaandGitHub 2387a609df Merge pull request #593 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-13 10:33:47 -04:00
Crowdin Bot 6457a84f4b New Crowdin translations by GitHub Action 2023-09-13 14:30:48 +00:00
Vitor Pamplona dee231d43a Moves deletion and report to be the last item on the dropdown menu: https://image.nostr.build/709c4f2753cf6a40f9f9322379394d09354acb7e88502b64be348e4a3030734b.png 2023-09-13 10:27:37 -04:00
greenart7c3 8c4af4ac85 only decrypt giftwrap in the dms screen 2023-09-13 11:10:10 -03:00
greenart7c3 58cf0d9f5d fix freeze when going to dms screen 2023-09-13 10:08:43 -03:00
greenart7c3 de5c0d4681 do not try decrypt private zaps for now 2023-09-13 09:32:24 -03:00
greenart7c3 780832d785 fix block/unblock 2023-09-13 09:22:34 -03:00
greenart7c3 b8808a17b5 fix bookmarks decryption 2023-09-13 08:38:20 -03:00
greenart7c3 5001e58c2b improve giftwrap decryption 2023-09-13 08:04:51 -03:00
greenart7c3 95514b66ea improve giftwrap decryption 2023-09-13 08:01:46 -03:00
greenart7c3 90c6d6c8f8 fix login with amber 2023-09-13 06:42:37 -03:00
Vitor Pamplona 88d2ce3ad0 Updating dependencies 2023-09-12 17:53:18 -04:00
Vitor PamplonaandGitHub 484f3a6842 Merge pull request #590 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-12 08:32:16 -04:00
Crowdin Bot 866caf8bc5 New Crowdin translations by GitHub Action 2023-09-12 12:12:41 +00:00
Vitor PamplonaandGitHub be9e3c3bf7 Merge pull request #589 from davotoula/main
Updated translations for cs/de/sv
2023-09-12 08:11:02 -04:00
David Kaspar 49a3cb1978 Updated translations for cs/de/sv 2023-09-12 11:41:43 +02:00
greenart7c3 c3ddc4f7ad fix release build not working 2023-09-11 18:12:55 -03:00
greenart7c3 ff4d704bde add support for private zaps 2023-09-11 15:30:00 -03:00
greenart7c3 a773be12c4 decrypt LnZapRequestEvent 2023-09-11 11:52:53 -03:00
greenart7c3andGitHub 7ff6f16df0 Merge branch 'main' into amber 2023-09-11 07:20:03 -03:00
Vitor PamplonaandGitHub d9053f580f Merge pull request #586 from OrigiSize/patch-1
Minor README.md edits
2023-09-09 19:37:57 -04:00
OrigiSizeandGitHub fee2764338 Minor README.md edits
Fixing grammar and flow
2023-09-09 12:33:09 -07:00
Vitor Pamplona 61cb4fdd74 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-09 14:42:59 -04:00
Vitor PamplonaandGitHub d89869ebff Merge pull request #585 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-09 14:40:45 -04:00
Vitor Pamplona 8518819dfa Forces valid hexes for users and notes 2023-09-09 14:39:41 -04:00
Crowdin Bot 0a8923be6b New Crowdin translations by GitHub Action 2023-09-09 18:34:33 +00:00
Vitor Pamplona cda821e5af Stops accepting space as a Valid hex char and requires an even number of chars (padding) 2023-09-09 14:33:19 -04:00
Vitor Pamplona be2402702f Forces the ZapRequest to exist when processing the LnZapEvent. 2023-09-09 14:32:27 -04:00
Vitor Pamplona d723a8ab5c Refactors the split between nip19 and the rest of the text. 2023-09-09 14:26:20 -04:00
Vitor Pamplona 5c882c81c8 Account is never null here. 2023-09-09 14:24:29 -04:00
Vitor PamplonaandGitHub 24d2709564 Merge pull request #584 from atrifat/move-broadcast-button-away
Fix vitorpamplona/amethyst#550 - change broadcast button position
2023-09-08 20:54:46 -04:00
Rif'at Ahdi R ce15bc46a7 Fix vitorpamplona/amethyst#550 - change broadcast button position 2023-09-09 08:24:17 +08:00
Vitor PamplonaandGitHub de4bc30730 Merge pull request #583 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-08 16:19:48 -04:00
Crowdin Bot 64f6811c00 New Crowdin translations by GitHub Action 2023-09-08 20:18:47 +00:00
greenart7c3andGitHub 36a3c4123c Merge branch 'main' into amber 2023-09-08 15:48:13 -03:00
greenart7c3 6303573dd9 support for sending and receiving nip 44 dms 2023-09-06 18:53:11 -03:00
greenart7c3 a7aa3705ef fix crash on reaction 2023-09-06 10:43:59 -03:00
greenart7c3 a64c941f36 add cache to decrypted dms 2023-09-06 09:21:55 -03:00
greenart7c3 59cde5699e add support for status 2023-09-04 18:28:11 -03:00
greenart7c3andGitHub bd836a9580 Merge branch 'main' into amber 2023-09-04 18:09:29 -03:00
greenart7c3 90aade338c sign event in background 2023-09-04 18:06:09 -03:00
greenart7c3 6d691c4741 add support for reports 2023-09-01 13:32:39 -03:00
greenart7c3 bbba27752d support for public and anon zaps 2023-09-01 12:25:42 -03:00
greenart7c3 d9cf090cc9 add support for nip94 2023-09-01 10:46:50 -03:00
greenart7c3 46effa572c refactor default values in signEvent variables 2023-09-01 10:18:49 -03:00
greenart7c3andGitHub f76ab01c11 Merge branch 'main' into amber 2023-09-01 05:12:29 -03:00
greenart7c3 d1cb699027 support for nip95 2023-08-30 16:40:11 -03:00
greenart7c3 9ee1250f3a unblock users 2023-08-30 14:15:20 -03:00
greenart7c3 7a68cf867e block users using amber 2023-08-30 11:54:36 -03:00
greenart7c3 bc8fa00608 move openAmber to AmberUtils 2023-08-30 08:29:56 -03:00
greenart7c3 329d806ffd decrypt dms of notifications 2023-08-30 08:07:45 -03:00
greenart7c3andGitHub e040d0f971 Merge branch 'main' into amber 2023-08-30 07:03:55 -03:00
greenart7c3 c223c1372b add support for amber in the follow dropdown menu 2023-08-28 14:31:20 -03:00
greenart7c3 a450dcbac1 add support for add/remove public bookmarks 2023-08-28 11:58:48 -03:00
greenart7c3 ee5fbce2ce refactor mainactivity to use IntentUtils.start 2023-08-28 09:56:12 -03:00
greenart7c3 559b88be18 fix build after merge 2023-08-28 07:18:04 -03:00
greenart7c3andGitHub dfbc5fa556 Merge branch 'main' into amber 2023-08-28 07:06:52 -03:00
greenart7c3 df1a017eb3 refactor PackageUtils.isAmberInstalled to account.loggedinwithamber 2023-08-25 11:51:22 -03:00
greenart7c3 b2b7799a0f add support for deletion events 2023-08-25 09:29:13 -03:00
greenart7c3 e817f94045 add/remove private bookmarks 2023-08-25 08:51:23 -03:00
greenart7c3 46571a6029 decrypt private bookmark 2023-08-25 06:56:49 -03:00
greenart7c3 4f4d689f5a fix build, use keyPair in textnoteevent, check if loggedInWithAmber in newpostview 2023-08-25 05:45:39 -03:00
greenart7c3andGitHub a0308938de Merge branch 'main' into amber 2023-08-25 05:27:14 -03:00
greenart7c3 b10e875941 created loggedInWithAmber in accountViewModel, changed some events to use keypair 2023-08-23 15:56:31 -03:00
greenart7c3 632400f52c add login with amber button 2023-08-23 13:43:25 -03:00
greenart7c3 b63479c45f fix build 2023-08-23 07:33:49 -03:00
greenart7c3andGitHub 7ea6495f28 Merge branch 'main' into amber 2023-08-23 07:28:58 -03:00
greenart7c3 453e63025d fix crash in PushNotificationUtils when theres no google play services 2023-08-21 15:58:01 -03:00
greenart7c3 17ecc98e1e Merge branch 'main' into amber 2023-08-21 14:59:20 -03:00
greenart7c3 f9272f5cfc implemented amber support for auth event and the notifications 2023-08-21 14:48:11 -03:00
greenart7c3 348e07a307 fix missing navScrollToTop parameter 2023-08-21 09:00:45 -03:00
greenart7c3andGitHub 2009f5bd7b Merge branch 'main' into amber 2023-08-21 08:50:56 -03:00
greenart7c3 7ea92bc068 send dms using amber 2023-08-18 10:57:17 -03:00
greenart7c3 3b07b5d8dd Refactor SignerDialog to use the rawJson and type 2023-08-18 07:55:14 -03:00
greenart7c3 f06e79cfd3 Merge branch 'main' into amber 2023-08-18 06:33:02 -03:00
greenart7c3andGitHub 584ceb1f0f Merge branch 'main' into amber 2023-08-16 17:21:47 -03:00
greenart7c3 8053119d42 decrypt dms using amber 2023-08-16 16:39:42 -03:00
greenart7c3 b8e97525eb add signer support whem replying 2023-08-16 14:34:11 -03:00
greenart7c3 66b1e4b15d add signer dialog to communities and public chats 2023-08-14 16:34:40 -03:00
greenart7c3 e6f6f64512 add signer dialog in the relay screen 2023-08-14 13:53:10 -03:00
greenart7c3 d2eea9f0ae fix merge 2023-08-14 10:51:14 -03:00
greenart7c3 012fdc8cc6 add signer support in the user metadata screen 2023-08-14 10:46:03 -03:00
greenart7c3 74054aab5a add signer support for sending notes 2023-08-14 10:46:02 -03:00
greenart7c3 11908b9e0c if amber is installed show floatingActionButton 2023-08-14 10:43:10 -03:00
greenart7c3 570cccfc62 add signer support in the profile tabs 2023-08-14 10:42:57 -03:00
greenart7c3 de9d6877e1 fix merge 2023-08-14 10:42:57 -03:00
greenart7c3 c31fa116ca add signerdialog to boosts 2023-08-14 10:42:57 -03:00
greenart7c3 10b15226a1 added SignerDialog in the like reaction 2023-08-14 10:42:57 -03:00
greenart7c3 7a46376bc1 added SignerDialog in the hashtagscreen 2023-08-14 10:42:57 -03:00
greenart7c3 b761aaf970 sign unfollow event 2023-08-14 10:42:57 -03:00
greenart7c3 3a08db2dfb add signerDialog in the profile screen follow button 2023-08-14 10:42:57 -03:00
greenart7c3 41fd2e087c use rememberLauncherForActivityResult to send the intent and get the result back 2023-08-14 10:42:57 -03:00
greenart7c3 ba476bcdca created SignerDialog 2023-08-14 10:42:56 -03:00
133 changed files with 6532 additions and 1546 deletions
+32
View File
@@ -0,0 +1,32 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>
+5 -5
View File
@@ -106,7 +106,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
This is a native Android app made with Kotlin and Jetpack Compose.
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
The overall architecture consists in the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
The overall architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
@@ -172,7 +172,7 @@ For the Play build:
keytool -genkey -v -keystore <my-release-key.keystore> -alias <alias_name> -keyalg RSA -keysize 2048 -validity 10000
openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.jks.base64.txt
```
2. Create 4 Secret Key variables on your GitHub repository and fill in with the signing key information
2. Create four Secret Key variables on your GitHub repository and fill in the signing key information
- `KEY_ALIAS` <- `<alias_name>`
- `KEY_PASSWORD` <- `<your password>`
- `KEY_STORE_PASSWORD` <- `<your key store password>`
@@ -192,15 +192,15 @@ The relay also learns which public keys you are requesting, meaning your public
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs.
# DM Privacy #
While the content of direct messages (DMs) is only visible to you, and your DM nostr counterparty, everyone can see that and when you and your counterparty are DM-ing each other.
While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
# Visibility & Permanence of Your Content on nostr
## Information Visibility ##
Content that you share can be shared to other relays.
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
Information that you share publicly is visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
## Information Permanence ##
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
# Screenshots
+3 -3
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 294
versionName "0.76.0"
versionCode 300
versionName "0.77.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -112,7 +112,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation "net.engawapg.lib:zoomable:1.5.0"
implementation 'net.engawapg.lib:zoomable:1.5.1'
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.RichTextParser
import com.vitorpamplona.amethyst.service.RichTextViewerState
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.EmptyTagList
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@@ -666,7 +666,7 @@ class RichTextParserTest {
@Test
fun testTextToParse() {
val state = RichTextParser().parseText(textToParse, ImmutableListOfLists())
val state = RichTextParser().parseText(textToParse, EmptyTagList)
Assert.assertEquals(
"relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl",
state.urlSet.joinToString(", ")
@@ -3989,7 +3989,7 @@ class RichTextParserTest {
@Test
fun testShortTextToParse() {
val state = RichTextParser().parseText("Hi, how are you doing? ", ImmutableListOfLists())
val state = RichTextParser().parseText("Hi, how are you doing? ", EmptyTagList)
Assert.assertTrue(state.urlSet.isEmpty())
Assert.assertTrue(state.imagesForPager.isEmpty())
Assert.assertTrue(state.imageList.isEmpty())
@@ -3999,7 +3999,7 @@ class RichTextParserTest {
@Test
fun testShortNewLinesTextToParse() {
val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", ImmutableListOfLists())
val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList)
Assert.assertTrue(state.urlSet.isEmpty())
Assert.assertTrue(state.imagesForPager.isEmpty())
Assert.assertTrue(state.imageList.isEmpty())
@@ -4019,7 +4019,7 @@ See how it can be done here: https://lnshort.it/live-stream-embeds/
https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg
""".trimIndent()
val state = RichTextParser().parseText(text, ImmutableListOfLists())
val state = RichTextParser().parseText(text, EmptyTagList)
Assert.assertEquals("https://lnshort.it/live-stream-embeds/", state.urlSet.firstOrNull())
Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imagesForPager.keys.firstOrNull())
Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imageList.firstOrNull()?.url)
@@ -4080,7 +4080,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
fun testNewLineAfterImage() {
val text = "Thats it ! Thats the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg "
val state = RichTextParser().parseText(text, ImmutableListOfLists())
val state = RichTextParser().parseText(text, EmptyTagList)
printStateForDebug(state)
@@ -4106,7 +4106,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
fun testSapceAfterImage() {
val text = "Thats it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg Thats the #note"
val state = RichTextParser().parseText(text, ImmutableListOfLists())
val state = RichTextParser().parseText(text, EmptyTagList)
printStateForDebug(state)
@@ -2,7 +2,8 @@ package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.AccountInfo
class PushNotificationUtils {
fun init(accounts: List<AccountInfo>) {
object PushNotificationUtils {
var hasInit: Boolean = true
suspend fun init(accounts: List<AccountInfo>) {
}
}
+2
View File
@@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="org.torproject.android"/>
<package android:name="com.greenart7c3.nostrsigner"/>
<package android:name="com.greenart7c3.nostrsigner.debug"/>
</queries>
<!-- Doesn't require a camera -->
@@ -3,10 +3,13 @@ package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Nip47URI
@@ -16,10 +19,8 @@ import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.LnZapEvent
@@ -35,7 +36,8 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
@Immutable
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean = false
val hasPrivKey: Boolean,
val loggedInWithExternalSigner: Boolean
)
private object PrefKeys {
@@ -74,7 +76,7 @@ private object PrefKeys {
const val THEME = "theme"
const val PREFERRED_LANGUAGE = "preferred_Language"
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
}
object LocalPreferences {
@@ -82,7 +84,7 @@ object LocalPreferences {
private var _currentAccount: String? = null
private fun currentAccount(): String? {
fun currentAccount(): String? {
if (_currentAccount == null) {
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
@@ -201,7 +203,11 @@ object LocalPreferences {
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts().map { npub ->
AccountInfo(npub = npub)
AccountInfo(
npub,
hasPrivKey(npub),
getLoggedInWithExternalSigner(npub)
)
}
}
@@ -245,6 +251,7 @@ object LocalPreferences {
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.loginWithExternalSigner)
}.apply()
val globalPrefs = encryptedPreferences()
@@ -276,17 +283,19 @@ object LocalPreferences {
}
fun getTheme(): Int {
encryptedPreferences().apply {
return getInt(PrefKeys.THEME, 0)
}
return encryptedPreferences().getInt(PrefKeys.THEME, 0)
}
fun getPreferredLanguage(): String {
var language = ""
encryptedPreferences().apply {
language = getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
}
return language
return encryptedPreferences().getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
}
private fun getLoggedInWithExternalSigner(npub: String): Boolean {
return encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
}
private fun hasPrivKey(npub: String): Boolean {
return (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank()
}
fun loadFromEncryptedStorage(): Account? {
@@ -316,11 +325,11 @@ object LocalPreferences {
val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let {
Event.mapper.readValue<List<Long>?>(it)
}?.ifEmpty { listOf(500L, 1000L, 5000L) } ?: listOf(500L, 1000L, 5000L)
}?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let {
Event.mapper.readValue<List<String>?>(it)
}?.ifEmpty { listOf("+") } ?: listOf("+")
}?.ifEmpty { DefaultReactions } ?: DefaultReactions
val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
@@ -335,16 +344,18 @@ object LocalPreferences {
Event.mapper.readValue<Nip47URI?>(it)
}
} catch (e: Throwable) {
Log.w("LocalPreferences", "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", e)
e.printStackTrace()
null
}
val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
println("Decoding Contact List: " + it)
Event.fromJson(it) as ContactListEvent?
}
} catch (e: Throwable) {
e.printStackTrace()
Log.w("LocalPreferences", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", e)
null
}
@@ -353,6 +364,7 @@ object LocalPreferences {
Event.mapper.readValue<Map<String, String>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
Log.w("LocalPreferences", "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", e)
e.printStackTrace()
mapOf()
}
@@ -363,6 +375,7 @@ object LocalPreferences {
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
@@ -377,6 +390,7 @@ object LocalPreferences {
Event.mapper.readValue<Map<String, Long>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
Log.w("LocalPreferences", "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", e)
e.printStackTrace()
mapOf()
}
@@ -431,51 +445,11 @@ object LocalPreferences {
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
settings = settings
settings = settings,
loginWithExternalSigner = loginWithExternalSigner
)
return a
}
}
fun migrateSingleUserPrefs() {
if (currentAccount() != null) return
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
val npub = Hex.decode(pubkey).toNpub()
val stringPrefs = listOf(
PrefKeys.NOSTR_PRIVKEY,
PrefKeys.NOSTR_PUBKEY,
PrefKeys.RELAYS,
PrefKeys.LANGUAGE_PREFS,
PrefKeys.TRANSLATE_TO,
PrefKeys.ZAP_AMOUNTS,
PrefKeys.LATEST_CONTACT_LIST
)
val stringSetPrefs = listOf(
PrefKeys.FOLLOWING_CHANNELS,
PrefKeys.HIDDEN_USERS,
PrefKeys.DONT_TRANSLATE_FROM
)
encryptedPreferences().apply {
val appPrefs = this
encryptedPreferences(npub).edit().apply {
val userPrefs = this
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
userPrefs.putBoolean(
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
)
}.apply()
}
encryptedPreferences().edit().clear().apply()
addAccount(npub)
updateCurrentAccount(npub)
}
}
@@ -92,9 +92,7 @@ class MultiPlayerPlaybackManager(
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
STATE_IDLE -> {
if (player.currentPosition > 5 * 60) { // 5 seconds
cachedPositions.add(uri, player.currentPosition)
}
cachedPositions.add(uri, player.currentPosition)
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
@@ -104,9 +102,7 @@ class MultiPlayerPlaybackManager(
}
}
else -> {
if (player.currentPosition > 5 * 60) { // 5 seconds
cachedPositions.add(uri, player.currentPosition)
}
cachedPositions.add(uri, player.currentPosition)
}
}
}
@@ -12,6 +12,7 @@ import coil.disk.DiskCache
import coil.util.DebugLogger
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
@@ -35,15 +36,21 @@ import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import java.io.File
object ServiceManager {
var shouldPauseService: Boolean = true // to not open amber in a loop trying to use auth relays and registering for notifications
private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications
private var account: Account? = null
fun start(account: Account, context: Context) {
this.account = account
ExternalSignerUtils.account = account
start(context)
}
@Synchronized
fun start(context: Context) {
if (isStarted && account != null) {
return
}
Log.d("ServiceManager", "Starting Relay Services")
val myAccount = account
@@ -88,6 +95,7 @@ object ServiceManager {
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
isStarted = true
}
}
@@ -113,6 +121,7 @@ object ServiceManager {
NostrVideoDataSource.stop()
Client.disconnect()
isStarted = false
}
fun cleanObservers() {
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -11,6 +13,7 @@ import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.*
@@ -53,6 +56,10 @@ object LocalCache {
// checkNotInMainThread()
return users[key] ?: run {
require(isValidHex(key = key)) {
"$key is not a valid hex"
}
val newObject = User(key)
users.putIfAbsent(key, newObject) ?: newObject
}
@@ -98,6 +105,10 @@ object LocalCache {
checkNotInMainThread()
return notes.get(idHex) ?: run {
require(isValidHex(idHex)) {
"$idHex is not a valid hex"
}
val newObject = Note(idHex)
notes.putIfAbsent(idHex, newObject) ?: newObject
}
@@ -198,6 +209,14 @@ object LocalCache {
val user = getOrCreateUser(event.pubKey)
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
if (event.dTag() == "bookmark") {
val loggedInUser = LocalPreferences.currentAccount()
val hexKey = loggedInUser?.bechToBytes()
if (hexKey != null) {
val pubKey = Hex.encode(hexKey)
if (pubKey == event.pubKey) {
ExternalSignerUtils.content.remove(event.id)
}
}
user.updateBookmark(event)
}
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
@@ -807,23 +826,23 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) {
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
return
}
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
(
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
(zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
)
note.loadEvent(event, author, repliesTo)
if (zapRequest == null) {
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
return
}
// Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
repliesTo.forEach {
@@ -43,6 +43,10 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
return minOf(publishedAt, lastCreatedAt)
}
fun dTag(): String? {
return (event as? AddressableEvent)?.dTag()
}
}
@Stable
@@ -5,16 +5,15 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
@@ -27,9 +26,6 @@ import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal
import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
@Stable
class User(val pubkeyHex: String) {
@@ -268,19 +264,11 @@ class User(val pubkeyHex: String) {
info?.updatedMetadataAt = latestMetadata.createdAt
info?.tags = latestMetadata.tags.toImmutableListOfLists()
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
try {
val url = String(Bech32.decodeBytes(newUserInfo.lud06!!, false).second)
val matcher = lnurlpPattern.matcher(url)
while (matcher.find()) {
val domain = matcher.group(2)
val username = matcher.group(3)
info?.lud16 = "$username@$domain"
if (newUserInfo.lud16.isNullOrBlank()) {
info?.lud06?.let {
if (it.lowercase().startsWith("lnurl")) {
info?.lud16 = Lud06().toLud16(it)
}
} catch (t: Throwable) {
// Doesn't create errors.
}
}
@@ -331,7 +319,7 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<HexKey> {
fun cachedFollowingTagSet(): Set<String> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
@@ -0,0 +1,311 @@
package com.vitorpamplona.amethyst.service
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.util.LruCache
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
enum class SignerType {
SIGN_EVENT,
NIP04_ENCRYPT,
NIP04_DECRYPT,
NIP44_ENCRYPT,
NIP44_DECRYPT,
GET_PUBLIC_KEY,
DECRYPT_ZAP_EVENT
}
object ExternalSignerUtils {
val content = LruCache<String, String>(10)
var isActivityRunning: Boolean = false
val cachedDecryptedContent = mutableMapOf<HexKey, String>()
lateinit var account: Account
private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var decryptResultLauncher: ActivityResultLauncher<Intent>
private lateinit var blockListResultLauncher: ActivityResultLauncher<Intent>
@OptIn(DelicateCoroutinesApi::class)
fun requestRejectedToast() {
GlobalScope.launch(Dispatchers.Main) {
Toast.makeText(
Amethyst.instance,
Amethyst.instance.getString(R.string.sign_request_rejected),
Toast.LENGTH_SHORT
).show()
}
}
@OptIn(DelicateCoroutinesApi::class)
fun default() {
isActivityRunning = false
ServiceManager.shouldPauseService = true
GlobalScope.launch(Dispatchers.IO) {
isActivityRunning = false
ServiceManager.shouldPauseService = true
}
}
@OptIn(DelicateCoroutinesApi::class)
fun start(activity: MainActivity) {
activityResultLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_OK) {
requestRejectedToast()
} else {
val event = it.data?.getStringExtra("signature") ?: ""
val id = it.data?.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
content.put(id, event)
}
}
default()
}
decryptResultLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_OK) {
requestRejectedToast()
} else {
val event = it.data?.getStringExtra("signature") ?: ""
val id = it.data?.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
content.put(id, event)
cachedDecryptedContent[id] = event
}
}
default()
}
blockListResultLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_OK) {
requestRejectedToast()
} else {
val decryptedContent = it.data?.getStringExtra("signature") ?: ""
val id = it.data?.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
cachedDecryptedContent[id] = decryptedContent
account.live.invalidateData()
}
}
default()
}
}
@OptIn(DelicateCoroutinesApi::class)
fun openSigner(
data: String,
type: SignerType,
intentResult: ActivityResultLauncher<Intent>,
pubKey: HexKey,
id: String
) {
try {
ServiceManager.shouldPauseService = false
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data"))
val signerType = when (type) {
SignerType.SIGN_EVENT -> "sign_event"
SignerType.NIP04_ENCRYPT -> "nip04_encrypt"
SignerType.NIP04_DECRYPT -> "nip04_decrypt"
SignerType.NIP44_ENCRYPT -> "nip44_encrypt"
SignerType.NIP44_DECRYPT -> "nip44_decrypt"
SignerType.GET_PUBLIC_KEY -> "get_public_key"
SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event"
}
intent.putExtra("type", signerType)
intent.putExtra("pubKey", pubKey)
intent.putExtra("id", id)
if (type !== SignerType.GET_PUBLIC_KEY) {
intent.putExtra("current_user", account.keyPair.pubKey.toNpub())
}
intent.`package` = "com.greenart7c3.nostrsigner"
intentResult.launch(intent)
} catch (e: Exception) {
Log.e("Signer", "Error opening Signer app", e)
GlobalScope.launch(Dispatchers.Main) {
Toast.makeText(
Amethyst.instance,
Amethyst.instance.getString(R.string.error_opening_external_signer),
Toast.LENGTH_SHORT
).show()
}
}
}
fun openSigner(event: EventInterface, columnName: String = "signature") {
checkNotInMainThread()
val result = getDataFromResolver(SignerType.SIGN_EVENT, arrayOf(event.toJson(), event.pubKey()), columnName)
if (result !== null) {
content.put(event.id(), result)
return
}
ServiceManager.shouldPauseService = false
isActivityRunning = true
openSigner(
event.toJson(),
SignerType.SIGN_EVENT,
activityResultLauncher,
"",
event.id()
)
while (isActivityRunning) {
Thread.sleep(100)
}
}
fun decryptBlockList(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
cachedDecryptedContent[id] = result
return
}
isActivityRunning = true
openSigner(
encryptedContent,
signerType,
blockListResultLauncher,
pubKey,
id
)
}
fun getDataFromResolver(signerType: SignerType, data: Array<out String>, columnName: String = "signature"): String? {
val localData = if (signerType !== SignerType.GET_PUBLIC_KEY) {
data.toList().plus(account.keyPair.pubKey.toNpub()).toTypedArray()
} else {
data
}
Amethyst.instance.contentResolver.query(
Uri.parse("content://com.greenart7c3.nostrsigner.$signerType"),
localData,
null,
null,
null
).use {
if (it !== null) {
if (it.moveToFirst()) {
val index = it.getColumnIndex(columnName)
if (index < 0) {
Log.d("getDataFromResolver", "column '$columnName' not found")
return null
}
return it.getString(index)
}
}
}
return null
}
fun decrypt(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
cachedDecryptedContent[id] = result
return
}
isActivityRunning = true
openSigner(
encryptedContent,
signerType,
decryptResultLauncher,
pubKey,
id
)
while (isActivityRunning) {
// do nothing
}
}
fun decryptDM(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
cachedDecryptedContent[id] = result
return
}
openSigner(
encryptedContent,
signerType,
decryptResultLauncher,
pubKey,
id
)
}
fun decryptBookmark(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
cachedDecryptedContent[id] = result
return
}
openSigner(
encryptedContent,
signerType,
decryptResultLauncher,
pubKey,
id
)
}
fun encrypt(decryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_ENCRYPT) {
val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
return
}
isActivityRunning = true
openSigner(
decryptedContent,
signerType,
activityResultLauncher,
pubKey,
id
)
while (isActivityRunning) {
Thread.sleep(100)
}
}
fun decryptZapEvent(event: LnZapRequestEvent) {
val result = getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey))
if (result !== null) {
content.put(event.id, result)
cachedDecryptedContent[event.id] = result
return
}
openSigner(
event.toJson(),
SignerType.DECRYPT_ZAP_EVENT,
decryptResultLauncher,
event.pubKey,
event.id
)
}
}
@@ -8,17 +8,26 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrAccountDataSource : NostrDataSource("AccountData") {
@@ -143,6 +152,20 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
event.cachedGift(privateKey)?.let {
this.consume(it, relay)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGift(account.keyPair.pubKey, cached)?.let {
this.consume(it, relay)
}
}
}
@@ -152,6 +175,20 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
event.cachedGossip(privateKey)?.let {
LocalCache.justConsume(it, relay)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
LocalCache.justConsume(it, relay)
}
}
// Don't store sealed gossips to avoid rebroadcasting by mistake.
@@ -181,7 +218,6 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
if (this::account.isInitialized) {
val event = account.createAuthEvent(relay, challenge)
if (event != null) {
Client.send(
event,
@@ -44,7 +44,7 @@ class NostrLnZapPaymentResponseDataSource(
override fun auth(relay: Relay, challenge: String) {
super.auth(relay, challenge)
val event = RelayAuthEvent.create(relay.url, challenge, authSigningKey)
val event = RelayAuthEvent.create(relay.url, challenge, "", authSigningKey)
Client.send(
event,
relay.url
@@ -0,0 +1,17 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
object PackageUtils {
private fun isPackageInstalled(context: Context, target: String): Boolean {
return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null
}
fun isOrbotInstalled(context: Context): Boolean {
return isPackageInstalled(context, "org.torproject.android")
}
fun isAmberInstalled(context: Context): Boolean {
return isPackageInstalled(context, "com.greenart7c3.nostrsigner")
}
}
@@ -0,0 +1,207 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.round
class ZapPaymentHandler(val account: Account) {
@Immutable
data class Payable(
val info: ZapSplitSetup,
val user: User?,
val amountMilliSats: Long,
val invoice: String
)
suspend fun zap(
note: Note,
amountMilliSats: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
zapType: LnZapEvent.ZapType
) = withContext(Dispatchers.IO) {
val zapSplitSetup = note.event?.zapSplitSetup()
val zapsToSend = if (!zapSplitSetup.isNullOrEmpty()) {
zapSplitSetup
} else {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
return@withContext
}
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
val totalWeight = zapsToSend.sumOf { it.weight }
val invoicesToPayOnIntent = mutableListOf<Payable>()
zapsToSend.forEachIndexed { index, value ->
val outerProgressMin = index / zapsToSend.size.toFloat()
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
val zapValue =
round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
if (value.isLnAddress) {
innerZap(
lud16 = value.lnAddressOrPubKeyHex,
note = note,
amount = zapValue,
pollOption = pollOption,
message = message,
context = context,
onError = onError,
onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
},
zapType = zapType,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = null,
amountMilliSats = zapValue,
invoice = it
)
)
}
)
} else {
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
val lud16 = user?.info?.lnAddress()
if (lud16 != null) {
innerZap(
lud16 = lud16,
note = note,
amount = zapValue,
pollOption = pollOption,
message = message,
context = context,
onError = onError,
onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
},
zapType = zapType,
overrideUser = user,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = user,
amountMilliSats = zapValue,
invoice = it
)
)
}
)
} else {
onError(
context.getString(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex
)
)
}
}
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f)
} else {
launch(Dispatchers.IO) {
// Awaits for the event to come back to LocalCache.
delay(5000)
onProgress(1f)
}
}
}
private suspend fun innerZap(
lud16: String,
note: Note,
amount: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayInvoiceThroughIntent: (String) -> Unit,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null
) {
var zapRequestJson = ""
if (zapType != LnZapEvent.ZapType.NONZAP) {
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser)
if (zapRequest != null) {
zapRequestJson = zapRequest.toJson()
}
}
onProgress(0.10f)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
response.error?.message
?: response.error?.code?.toString()
?: "Error parsing error message"
)
} else {
onProgress(1f)
}
}
)
onProgress(0.8f)
} else {
try {
onPayInvoiceThroughIntent(it)
} catch (e: Exception) {
onError(context.getString(R.string.lightning_wallets_not_found2))
}
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress
)
}
}
@@ -4,16 +4,14 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toLnUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
class LightningAddressResolver() {
@@ -27,11 +25,7 @@ class LightningAddressResolver() {
}
if (lnaddress.lowercase().startsWith("lnurl")) {
return try {
String(Bech32.decodeBytes(lnaddress, false).second)
} catch (e: Exception) {
null
}
return Lud06().toLnUrlp(lnaddress)
}
return null
@@ -66,7 +60,7 @@ class LightningAddressResolver() {
}
}
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
@@ -82,22 +76,13 @@ class LightningAddressResolver() {
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(response.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
client.newCall(request).execute().use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
}
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
@@ -115,7 +100,7 @@ class LightningAddressResolver() {
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
@@ -159,13 +144,14 @@ class LightningAddressResolver() {
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
// Forces LN Invoice amount to be the requested amount.
val expectedAmountInSats = BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
if (invoiceAmount.toLong() == expectedAmountInSats) {
onProgress(0.7f)
onSuccess(pr)
} else {
onProgress(0.0f)
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from $lnaddress. It should have been $expectedAmountInSats")
}
} ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason ->
onProgress(0.0f)
@@ -7,6 +7,8 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.SignerType
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
@@ -32,27 +34,56 @@ class EventNotificationConsumer(private val applicationContext: Context) {
// Test with all logged in accounts
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.keyPair.privKey != null) {
if (acc != null && (acc.keyPair.privKey != null || acc.loginWithExternalSigner)) {
consumeIfMatchesAccount(event, acc)
}
}
}
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
val key = account.keyPair.privKey ?: return
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
val key = account.keyPair.privKey
if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
pushWrappedEvent.content,
pushWrappedEvent.pubKey,
pushWrappedEvent.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id] ?: ""
}
pushWrappedEvent.unwrap(cached)?.let { notificationEvent ->
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
LocalCache.justConsume(notificationEvent, null)
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
} else if (key != null) {
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
}
@@ -63,17 +94,54 @@ class EventNotificationConsumer(private val applicationContext: Context) {
return when (event) {
is GiftWrapEvent -> {
val key = account.keyPair.privKey ?: return null
event.cachedGift(key)?.let {
unwrapAndConsume(it, account)
val key = account.keyPair.privKey
if (key != null) {
event.cachedGift(key)?.let {
unwrapAndConsume(it, account)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGift(account.keyPair.pubKey, cached)?.let {
unwrapAndConsume(it, account)
}
} else {
null
}
}
is SealedGossipEvent -> {
val key = account.keyPair.privKey ?: return null
event.cachedGossip(key)?.let {
// this is not verifiable
LocalCache.justConsume(it, null)
it
val key = account.keyPair.privKey
if (key != null) {
event.cachedGossip(key)?.let {
// this is not verifiable
LocalCache.justConsume(it, null)
it
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
LocalCache.justConsume(it, null)
it
}
} else {
null
}
}
else -> {
@@ -154,7 +222,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
@@ -23,7 +23,7 @@ class RegisterAccounts(
): List<RelayAuthEvent> {
return accounts.mapNotNull {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null) {
if (acc != null && acc.isWriteable()) {
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
if (relayToUse != null) {
acc.createAuthEvent(relayToUse, notificationToken)
@@ -36,7 +36,7 @@ class RegisterAccounts(
}
}
private fun postRegistrationEvent(events: List<RelayAuthEvent>) {
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
try {
val jsonObject = """{
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.lang.IllegalArgumentException
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
@@ -64,7 +65,11 @@ suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).get().build()
val html = HttpClient.getHttpClient().newCall(request).execute().use {
it.body.string()
if (it.isSuccessful) {
it.body.string()
} else {
throw IllegalArgumentException("Website returned: " + it.code)
}
}
Jsoup.parse(html)
@@ -23,6 +23,7 @@ import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
@@ -51,9 +52,10 @@ import java.nio.charset.StandardCharsets
class MainActivity : AppCompatActivity() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
ExternalSignerUtils.start(this)
super.onCreate(savedInstanceState)
LocalPreferences.migrateSingleUserPrefs()
val language = LocalPreferences.getPreferredLanguage()
if (language.isNotBlank()) {
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
@@ -99,11 +101,13 @@ class MainActivity : AppCompatActivity() {
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start(this@MainActivity)
if (ServiceManager.shouldPauseService) {
ServiceManager.start(this@MainActivity)
}
}
GlobalScope.launch(Dispatchers.IO) {
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
PushNotificationUtils.init(LocalPreferences.allSavedAccounts())
}
}
@@ -113,7 +117,9 @@ class MainActivity : AppCompatActivity() {
debugState(this)
// }
ServiceManager.pause()
if (ServiceManager.shouldPauseService) {
ServiceManager.pause()
}
super.onPause()
}
@@ -145,8 +151,10 @@ class MainActivity : AppCompatActivity() {
Log.d("NETWORKCALLBACK", "onAvailable: Disconnecting and connecting again")
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.pause()
ServiceManager.start(this@MainActivity)
if (ServiceManager.shouldPauseService) {
ServiceManager.pause()
ServiceManager.start(this@MainActivity)
}
}
}
@@ -176,7 +184,9 @@ class MainActivity : AppCompatActivity() {
Log.d("NETWORKCALLBACK", "onLost: Disconnecting and pausing relay's connection")
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.pause()
if (ServiceManager.shouldPauseService) {
ServiceManager.pause()
}
}
}
}
@@ -92,24 +92,32 @@ open class NewMediaModel : ViewModel() {
} else {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description, sensitiveContent, relayList = relayList)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
viewModelScope.launch(Dispatchers.IO) {
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(
imageUrl,
mimeType,
description,
sensitiveContent,
relayList = relayList
)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
)
)
}
}
},
onError = {
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -186,8 +187,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
val resolver = LocalContext.current.contentResolver
Row(
@@ -252,7 +252,6 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {
postViewModel.selectedServer = fileServers[it].first
},
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.model.LocalCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.crypto.KeyPair
@@ -13,7 +13,8 @@ class NewMessageTagger(
var message: String,
var mentions: List<User>? = null,
var replyTos: List<Note>? = null,
var channelHex: String? = null
var channelHex: String? = null,
var dao: Dao
) {
val directMentions = mutableSetOf<HexKey>()
@@ -40,20 +41,20 @@ class NewMessageTagger(
return (if (channelHex != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun run() {
suspend fun run() {
// adds all references to mentions and reply tos
message.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
addUserToMentions(dao.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
addNoteToReplyTos(note)
}
@@ -66,19 +67,19 @@ class NewMessageTagger(
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
val user = dao.getOrCreateUser(results.key.hex)
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
val note = dao.getOrCreateNote(results.key.hex)
"nostr:${note.toNEvent()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
val note = dao.getOrCreateNote(results.key.hex)
"nostr:${note.toNEvent()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
"nostr:${note.idNote()}${results.restOfWord}"
} else {
@@ -91,6 +92,7 @@ class NewMessageTagger(
}.joinToString("\n")
}
@Immutable
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
@@ -101,37 +103,50 @@ class NewMessageTagger(
key = key.removePrefix("@")
if (key.length < 63) {
return null
}
try {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
if (key.startsWith("nsec1", true)) {
if (key.length < 63) {
return null
}
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
// Converts to npub
val pubkey = Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("npub1", true)) {
if (key.length < 63) {
return null
}
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("note1", true)) {
if (key.length < 63) {
return null
}
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val noteId = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(noteId, restOfWord)
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
val pubkeyRelay = Nip19.uriToRoute(key) ?: return null
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
} else if (key.startsWith("nevent1", true)) {
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
val noteRelayId = Nip19.uriToRoute(key) ?: return null
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
} else if (key.startsWith("naddr1", true)) {
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
val address = Nip19.uriToRoute(key) ?: return null
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
}
@@ -142,3 +157,9 @@ class NewMessageTagger(
return null
}
}
interface Dao {
suspend fun getOrCreateUser(hex: String): User
suspend fun getOrCreateNote(hex: String): Note
suspend fun checkGetOrCreateAddressableNote(hex: String): Note?
}
@@ -9,6 +9,7 @@ import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -82,20 +83,25 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -111,6 +117,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.Math.round
@Composable
fun NewPostView(
@@ -136,7 +143,7 @@ fun NewPostView(
var relayList = account.activeWriteRelays()
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
postViewModel.load(accountViewModel, baseReplyTo, quote)
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
@@ -205,10 +212,8 @@ fun NewPostView(
}
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost(relayList = relayList)
onClose()
}
postViewModel.sendPost(relayList = relayList)
onClose()
},
isActive = postViewModel.canPost()
)
@@ -321,9 +326,9 @@ fun NewPostView(
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp)
) {
FowardZapTo(postViewModel)
FowardZapTo(postViewModel, accountViewModel)
}
}
@@ -721,7 +726,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
}
@Composable
fun FowardZapTo(postViewModel: NewPostViewModel) {
fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) {
Column(
modifier = Modifier.fillMaxWidth()
) {
@@ -755,7 +760,7 @@ fun FowardZapTo(postViewModel: NewPostViewModel) {
}
Text(
text = stringResource(R.string.zap_forward_title),
text = stringResource(R.string.zap_split_title),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
@@ -765,22 +770,52 @@ fun FowardZapTo(postViewModel: NewPostViewModel) {
Divider()
Text(
text = stringResource(R.string.zap_forward_explainer),
text = stringResource(R.string.zap_split_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
postViewModel.forwardZapTo.items.forEachIndexed { index, splitItem ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
BaseUserPicture(splitItem.key, Size55dp, accountViewModel = accountViewModel)
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(splitItem.key, showPlayButton = false)
Text(
text = String.format("%.0f%%", splitItem.percentage * 100),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Spacer(modifier = DoubleHorzSpacer)
Slider(
value = splitItem.percentage,
onValueChange = { sliderValue ->
val rounded = (round(sliderValue * 20)) / 20.0f
postViewModel.updateZapPercentage(index, rounded)
},
modifier = Modifier
.weight(1.5f)
)
}
}
OutlinedTextField(
value = postViewModel.forwardZapToEditting,
onValueChange = {
postViewModel.updateZapForwardTo(it)
},
label = { Text(text = stringResource(R.string.zap_forward_lnAddress)) },
label = { Text(text = stringResource(R.string.zap_split_serarch_and_add_user)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.zap_forward_lnAddress),
text = stringResource(R.string.zap_split_serarch_and_add_user_placeholder),
color = MaterialTheme.colors.placeholderText
)
},
@@ -1284,8 +1319,7 @@ fun ImageVideoDescription(
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
var selectedServer by remember { mutableStateOf(defaultServer) }
var message by remember { mutableStateOf("") }
@@ -1399,7 +1433,6 @@ fun ImageVideoDescription(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {
selectedServer = fileServers[it].first
},
@@ -21,7 +21,9 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
@@ -29,6 +31,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -43,6 +46,7 @@ enum class UserSuggestionAnchor {
@Stable
open class NewPostViewModel() : ViewModel() {
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var requiresNIP24: Boolean = false
@@ -88,9 +92,13 @@ open class NewPostViewModel() : ViewModel() {
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
data class ForwardZapSetup(val user: User) {
var percentage by mutableStateOf(100)
}
// Forward Zap to
var wantsForwardZapTo by mutableStateOf(false)
var forwardZapTo by mutableStateOf<User?>(null)
var forwardZapTo by mutableStateOf<Split<User>>(Split())
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
// NSFW, Sensitive
@@ -109,7 +117,7 @@ open class NewPostViewModel() : ViewModel() {
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
open fun load(accountViewModel: AccountViewModel, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
if (replyNote.event is BaseTextNoteEvent) {
@@ -141,8 +149,8 @@ open class NewPostViewModel() : ViewModel() {
urlPreview = findUrlInMessage()
}
canAddInvoice = account.userProfile().info?.lnAddress() != null
canAddZapRaiser = account.userProfile().info?.lnAddress() != null
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
@@ -151,25 +159,40 @@ open class NewPostViewModel() : ViewModel() {
wantsToAddGeoHash = false
wantsZapraiser = false
zapRaiserAmount = null
forwardZapTo = null
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
this.account = account
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun sendPost(relayList: List<Relay>? = null) {
val tagger = NewMessageTagger(message.text, mentions, replyTos, originalNote?.channelHex())
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList)
}
}
suspend fun innerSendPost(relayList: List<Relay>? = null) {
if (accountViewModel == null) {
cancel()
return
}
val tagger = NewMessageTagger(message.text, mentions, replyTos, originalNote?.channelHex(), accountViewModel!!)
tagger.run()
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null)
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!)
toUsersTagger.run()
val dmUsers = toUsersTagger.mentions
val zapReceiver = if (wantsForwardZapTo) {
if (forwardZapTo != null) {
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
} else {
forwardZapToEditting.text
forwardZapTo?.items?.map {
ZapSplitSetup(
lnAddressOrPubKeyHex = it.key.pubkeyHex,
relay = it.key.relaysBeingUsed.keys.firstOrNull(),
weight = it.percentage.toDouble(),
isLnAddress = false
)
}
} else {
null
@@ -355,7 +378,7 @@ open class NewPostViewModel() : ViewModel() {
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
forwardZapTo = null
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
userSuggestions = emptyList()
@@ -427,10 +450,10 @@ open class NewPostViewModel() : ViewModel() {
open fun updateZapForwardTo(it: TextFieldValue) {
forwardZapToEditting = it
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWord = it.text
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
if (lastWord.startsWith("@") && lastWord.length > 2) {
if (lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
@@ -460,6 +483,9 @@ open class NewPostViewModel() : ViewModel() {
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo?.addItem(item)
forwardZapToEditting = TextFieldValue("")
/*
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
@@ -468,7 +494,7 @@ open class NewPostViewModel() : ViewModel() {
forwardZapToEditting = TextFieldValue(
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
)*/
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
@@ -656,6 +682,10 @@ open class NewPostViewModel() : ViewModel() {
isValidvalueMaximum.value = true
}
}
fun updateZapPercentage(index: Int, sliderValue: Float) {
forwardZapTo?.updatePercentage(index, sliderValue)
}
}
enum class GeohashPrecision(val digits: Int) {
@@ -17,6 +17,7 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -37,6 +38,7 @@ import kotlinx.coroutines.withContext
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
val postViewModel: NewUserMetadataViewModel = viewModel()
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
postViewModel.load(account)
@@ -82,7 +84,9 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
}
Column(
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState())
modifier = Modifier
.padding(10.dp)
.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier.fillMaxWidth(1f),
@@ -124,7 +124,6 @@ class NewUserMetadataViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
}
clear()
}
@@ -183,23 +182,25 @@ class NewUserMetadataViewModel : ViewModel() {
contentResolver.getType(galleryUri),
context.applicationContext,
onReady = { fileUri, contentType, size ->
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = account.defaultFileServer,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
onUploading(false)
onUploaded(imageUrl)
},
onError = {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
viewModelScope.launch(Dispatchers.IO) {
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = account.defaultFileServer,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
onUploading(false)
onUploaded(imageUrl)
},
onError = {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
)
)
}
},
onError = {
onUploading(false)
@@ -248,7 +248,7 @@ private fun DisplayOwnerInformation(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadUser(baseUserHex = userHex) {
LoadUser(baseUserHex = userHex, accountViewModel) {
Crossfade(it) {
if (it != null) {
UserCompose(baseUser = it, accountViewModel = accountViewModel, showDiviser = false, nav = nav)
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewCommunityNoteButton(communityIdHex: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadNote(baseNoteHex = communityIdHex) {
LoadNote(baseNoteHex = communityIdHex, accountViewModel) {
it?.let {
NewCommunityNoteButton(it, accountViewModel, nav)
}
@@ -40,12 +40,12 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@@ -61,20 +61,21 @@ import kotlinx.coroutines.launch
@Composable
fun ClickableRoute(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (nip19.type) {
Nip19.Type.USER -> {
DisplayUser(nip19, nav)
DisplayUser(nip19, accountViewModel, nav)
}
Nip19.Type.ADDRESS -> {
DisplayAddress(nip19, nav)
DisplayAddress(nip19, accountViewModel, nav)
}
Nip19.Type.NOTE -> {
DisplayNote(nip19, nav)
DisplayNote(nip19, accountViewModel, nav)
}
Nip19.Type.EVENT -> {
DisplayEvent(nip19, nav)
DisplayEvent(nip19, accountViewModel, nav)
}
else -> {
Text(
@@ -86,34 +87,15 @@ fun ClickableRoute(
}
}
@Composable
private fun LoadNote(
hex: String,
content: @Composable (Note) -> Unit
) {
var noteBase by remember(hex) { mutableStateOf(LocalCache.getNoteIfExists(hex)) }
if (noteBase == null) {
LaunchedEffect(key1 = hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(hex)
}
}
}
noteBase?.let {
content(it)
}
}
@Composable
private fun DisplayEvent(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadNote(nip19.hex) {
LoadNote(nip19.hex, accountViewModel) {
if (it != null) {
DisplayNoteLink(it, nip19, nav)
DisplayNoteLink(it, nip19, accountViewModel, nav)
} else {
CreateClickableText(
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
@@ -128,11 +110,12 @@ private fun DisplayEvent(
@Composable
private fun DisplayNote(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadNote(nip19.hex) {
LoadNote(nip19.hex, accountViewModel = accountViewModel) {
if (it != null) {
DisplayNoteLink(it, nip19, nav)
DisplayNoteLink(it, nip19, accountViewModel, nav)
} else {
CreateClickableText(
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
@@ -148,6 +131,7 @@ private fun DisplayNote(
private fun DisplayNoteLink(
it: Note,
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteState by it.live().metadata.observeAsState()
@@ -177,7 +161,7 @@ private fun DisplayNoteLink(
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
@@ -205,14 +189,15 @@ private fun DisplayNoteLink(
@Composable
private fun DisplayAddress(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var noteBase by remember(nip19) { mutableStateOf(LocalCache.getNoteIfExists(nip19.hex)) }
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) }
if (noteBase == null) {
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) {
noteBase = it
}
}
}
@@ -244,14 +229,19 @@ private fun DisplayAddress(
@Composable
private fun DisplayUser(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var userBase by remember(nip19) { mutableStateOf(LocalCache.getUserIfExists(nip19.hex)) }
var userBase by remember(nip19) {
mutableStateOf(
accountViewModel.getUserIfExists(nip19.hex)
)
}
if (userBase == null) {
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
accountViewModel.checkGetOrCreateUser(nip19.hex) {
userBase = it
}
}
}
@@ -243,7 +243,7 @@ private fun RenderWordWithoutPreview(
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
is HashIndexUserSegment -> TagLink(word, nav)
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> NormalWord(word.segmentText, style)
@@ -270,7 +270,7 @@ private fun RenderWordWithPreview(
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
is HashIndexUserSegment -> TagLink(word, nav)
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> NormalWord(word.segmentText, style)
@@ -654,7 +654,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
}
} else if (loadedLink?.nip19 != null) {
Row() {
ClickableRoute(loadedLink?.nip19!!, nav)
ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav)
}
} else {
val text = remember {
@@ -763,8 +763,8 @@ private fun InlineIcon(hashtagIcon: HashtagIcon) =
}
@Composable
fun TagLink(word: HashIndexUserSegment, nav: (String) -> Unit) {
LoadUser(baseUserHex = word.hex) {
fun TagLink(word: HashIndexUserSegment, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadUser(baseUserHex = word.hex, accountViewModel) {
if (it == null) {
Text(text = word.segmentText)
} else {
@@ -776,15 +776,15 @@ fun TagLink(word: HashIndexUserSegment, nav: (String) -> Unit) {
}
@Composable
fun LoadNote(baseNoteHex: String, content: @Composable (Note?) -> Unit) {
fun LoadNote(baseNoteHex: String, accountViewModel: AccountViewModel, content: @Composable (Note?) -> Unit) {
var note by remember(baseNoteHex) {
mutableStateOf<Note?>(LocalCache.getNoteIfExists(baseNoteHex))
mutableStateOf<Note?>(accountViewModel.getNoteIfExists(baseNoteHex))
}
if (note == null) {
LaunchedEffect(key1 = baseNoteHex) {
launch(Dispatchers.IO) {
note = LocalCache.checkGetOrCreateNote(baseNoteHex)
accountViewModel.checkGetOrCreateNote(baseNoteHex) {
note = it
}
}
}
@@ -794,7 +794,7 @@ fun LoadNote(baseNoteHex: String, content: @Composable (Note?) -> Unit) {
@Composable
fun TagLink(word: HashIndexEventSegment, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadNote(baseNoteHex = word.hex) {
LoadNote(baseNoteHex = word.hex, accountViewModel) {
if (it == null) {
Text(text = remember { word.segmentText.toShortenHex() })
} else {
@@ -0,0 +1,99 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlin.math.abs
class SplitItem<T>(val key: T) {
var percentage by mutableStateOf(0f)
}
class Split<T>() {
var items: List<SplitItem<T>> by mutableStateOf(emptyList())
fun addItem(key: T): Int {
val wasEqualSplit = isEqualSplit()
val newItem = SplitItem(key)
items = items.plus(newItem)
if (wasEqualSplit) {
forceEqualSplit()
} else {
updatePercentage(items.lastIndex, equalSplit())
}
return items.lastIndex
}
fun equalSplit() = 1f / items.size
fun isEqualSplit(): Boolean {
val expectedPercentage = equalSplit()
return items.all { (it.percentage - expectedPercentage) < 0.01 }
}
fun forceEqualSplit() {
val correctPercentage = equalSplit()
items.forEach {
it.percentage = correctPercentage
}
}
fun updatePercentage(index: Int, percentage: Float) {
if (items.isEmpty()) return
val splitItem = items.getOrNull(index) ?: return
if (items.size == 1) {
splitItem.percentage = 1f
} else {
splitItem.percentage = percentage
println("Update ${items[index].key} to $percentage")
val othersMustShare = 1.0f - splitItem.percentage
val othersHave = items.sumOf {
if (it == splitItem) 0.0 else it.percentage.toDouble()
}.toFloat()
if (abs(othersHave - othersMustShare) < 0.01) return // nothing to do
println("Others Must Share $othersMustShare but have $othersHave")
bottomUpAdjustment(othersMustShare, othersHave, index)
}
}
private fun bottomUpAdjustment(othersMustShare: Float, othersHave: Float, exceptForIndex: Int) {
var needToRemove = othersHave - othersMustShare
if (needToRemove > 0) {
for (i in items.indices.reversed()) {
if (i == exceptForIndex) continue // do not update the current item
if (needToRemove < items[i].percentage) {
val oldValue = items[i].percentage
items[i].percentage -= needToRemove
needToRemove = 0f
println("- Updating ${items[i].key} from $oldValue to ${items[i].percentage - needToRemove}. $needToRemove left")
} else {
val oldValue = items[i].percentage
needToRemove -= items[i].percentage
items[i].percentage = 0f
println("- Updating ${items[i].key} from $oldValue to ${0}. $needToRemove left")
}
if (needToRemove < 0.01) {
break
}
}
} else if (needToRemove < 0) {
if (items.lastIndex == exceptForIndex) {
items[items.lastIndex - 1].percentage += -needToRemove
} else {
items.last().percentage += -needToRemove
}
}
}
}
@@ -20,6 +20,7 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -37,8 +38,7 @@ import kotlinx.collections.immutable.ImmutableList
fun TextSpinner(
label: String?,
placeholder: String,
options: ImmutableList<String>,
explainers: ImmutableList<String>? = null,
options: ImmutableList<TitleExplainer>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -74,8 +74,8 @@ fun TextSpinner(
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it].title
optionsShowing = false
onSelect(it)
}
@@ -85,10 +85,40 @@ fun TextSpinner(
@Composable
fun SpinnerSelectionDialog(
options: ImmutableList<String>,
explainers: ImmutableList<String>?,
options: ImmutableList<TitleExplainer>,
onDismiss: () -> Unit,
onSelect: (Int) -> Unit
) {
SpinnerSelectionDialog(
options = options,
onSelect = onSelect,
onDismiss = onDismiss
) { item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = item.title, color = MaterialTheme.colors.onSurface)
}
item.explainer?.let {
Spacer(modifier = Modifier.height(5.dp))
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = it, color = Color.Gray, fontSize = Font14SP)
}
}
}
}
@Composable
fun <T> SpinnerSelectionDialog(
options: ImmutableList<T>,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
onRenderItem: @Composable (T) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
@@ -106,23 +136,7 @@ fun SpinnerSelectionDialog(
}
) {
Column() {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
}
explainers?.getOrNull(index)?.let {
Spacer(modifier = Modifier.height(5.dp))
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = it, color = Color.Gray, fontSize = Font14SP)
}
}
onRenderItem(item)
}
}
if (index < options.lastIndex) {
@@ -133,3 +147,6 @@ fun SpinnerSelectionDialog(
}
}
}
@Immutable
data class TitleExplainer(val title: String, val explainer: String? = null)
@@ -0,0 +1,11 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Immutable
@Immutable
data class TranslationConfig(
val result: String?,
val sourceLang: String?,
val targetLang: String?,
val showOriginal: Boolean
)
@@ -62,7 +62,7 @@ fun UrlPreviewCard(
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
.padding(start = 10.dp, end = 10.dp, top = 5.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -29,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -612,7 +613,7 @@ fun Waveform(
DrawWaveform(waveform, waveformProgress, modifier)
val restartFlow = remember {
mutableStateOf(0)
mutableIntStateOf(0)
}
// Keeps the screen on while playing and viewing videos.
@@ -477,14 +477,16 @@ private fun AddedImageFeatures(
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
FlowRow(Modifier.fillMaxWidth()) {
DisplayUrlWithLoadingSymbol(content)
}
}
}
is AsyncImagePainter.State.Error -> {
ClickableUrl(urlText = "${content.url} ", url = content.url)
FlowRow(Modifier.fillMaxWidth()) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
}
is AsyncImagePainter.State.Success -> {
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.quartz.encoders.toHexKey
object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
@@ -12,18 +14,44 @@ object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
}
override fun feed(): List<Note> {
val privKey = account.keyPair.privKey ?: return emptyList()
val bookmarks = account.userProfile().latestBookmarkList
val notes = bookmarks?.privateTaggedEvents(privKey)
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
if (account.loginWithExternalSigner) {
val id = bookmarks?.id
if (id != null) {
val decryptedContent = ExternalSignerUtils.cachedDecryptedContent[id]
if (decryptedContent == null) {
ExternalSignerUtils.decryptBookmark(
bookmarks.content,
account.keyPair.pubKey.toHexKey(),
id
)
} else {
bookmarks.decryptedContent = decryptedContent
}
}
val decryptedContent = ExternalSignerUtils.cachedDecryptedContent[id] ?: ""
val addresses = bookmarks?.privateTaggedAddresses(privKey)
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
val notes = bookmarks?.privateTaggedEvents(decryptedContent)
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
val addresses = bookmarks?.privateTaggedAddresses(decryptedContent)
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
return notes.plus(addresses).toSet()
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
} else {
val privKey = account.keyPair.privKey ?: return emptyList()
val notes = bookmarks?.privateTaggedEvents(privKey)
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
val addresses = bookmarks?.privateTaggedAddresses(privKey)
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
}
@@ -14,7 +14,18 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
}
override fun feed(): List<User> {
return account.getBlockList()
val blockList = account.getBlockList()
val decryptedContent = blockList?.decryptedContent ?: ""
if (account.loginWithExternalSigner) {
if (decryptedContent.isEmpty()) return emptyList()
return blockList
?.publicAndPrivateUsers(decryptedContent)
?.map { LocalCache.getOrCreateUser(it) }
?: emptyList()
}
return blockList
?.publicAndPrivateUsers(account.keyPair.privKey)
?.map { LocalCache.getOrCreateUser(it) }
?: emptyList()
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
@@ -54,11 +55,13 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry
import coil.Coil
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
@@ -88,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.LongCommunityHeader
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
@@ -117,6 +121,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -227,7 +232,7 @@ private fun CommunityTopBar(
nav: (String) -> Unit,
navPopBack: () -> Unit
) {
LoadAddressableNote(aTagHex = id) { baseNote ->
LoadAddressableNote(aTagHex = id, accountViewModel) { baseNote ->
if (baseNote != null) {
FlexibleTopBarWithBackButton(
title = {
@@ -288,7 +293,7 @@ private fun RenderRoomTopBar(
if (room.users.size == 1) {
FlexibleTopBarWithBackButton(
title = {
LoadUser(baseUserHex = room.users.first()) { baseUser ->
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
if (baseUser != null) {
ClickableUserPicture(
baseUser = baseUser,
@@ -303,7 +308,7 @@ private fun RenderRoomTopBar(
}
},
extendableRow = {
LoadUser(baseUserHex = room.users.first()) {
LoadUser(baseUserHex = room.users.first(), accountViewModel) {
if (it != null) {
UserCompose(
baseUser = it,
@@ -348,7 +353,7 @@ private fun ChannelTopBar(
nav: (String) -> Unit,
navPopBack: () -> Unit
) {
LoadChannel(baseChannelHex = id) { baseChannel ->
LoadChannel(baseChannelHex = id, accountViewModel) { baseChannel ->
FlexibleTopBarWithBackButton(
prefixRow = {
if (baseChannel is LiveActivitiesChannel) {
@@ -382,11 +387,12 @@ fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState
val list by accountViewModel.storiesListLiveData.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
list,
true
followListsModel = followLists,
listName = list,
withGlobal = true,
withRoutes = false
) { listName ->
accountViewModel.account.changeDefaultStoriesFollowList(listName)
accountViewModel.account.changeDefaultStoriesFollowList(listName.code)
}
}
}
@@ -397,11 +403,16 @@ fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, a
val list by accountViewModel.homeListLiveData.observeAsState(KIND3_FOLLOWS)
FollowList(
followLists,
list,
true
followListsModel = followLists,
listName = list,
withGlobal = true,
withRoutes = true
) { listName ->
accountViewModel.account.changeDefaultHomeFollowList(listName)
if (listName.type == CodeNameType.ROUTE) {
nav(listName.code)
} else {
accountViewModel.account.changeDefaultHomeFollowList(listName.code)
}
}
}
}
@@ -412,11 +423,12 @@ fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: Scaffold
val list by accountViewModel.notificationListLiveData.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
list,
true
followListsModel = followLists,
listName = list,
withGlobal = true,
withRoutes = false
) { listName ->
accountViewModel.account.changeDefaultNotificationFollowList(listName)
accountViewModel.account.changeDefaultNotificationFollowList(listName.code)
}
}
}
@@ -427,11 +439,12 @@ fun DiscoveryTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldSta
val list by accountViewModel.discoveryListLiveData.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
list,
true
followListsModel = followLists,
listName = list,
withGlobal = true,
withRoutes = false
) { listName ->
accountViewModel.account.changeDefaultDiscoveryFollowList(listName)
accountViewModel.account.changeDefaultDiscoveryFollowList(listName.code)
}
}
}
@@ -523,37 +536,81 @@ private fun LoggedInUserPictureDrawer(
}
@Composable
fun FollowList(followListsModel: FollowListViewModel, listName: String, withGlobal: Boolean, onChange: (String) -> Unit) {
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
fun FollowList(
followListsModel: FollowListViewModel,
listName: String,
withGlobal: Boolean,
withRoutes: Boolean,
onChange: (CodeName) -> Unit
) {
val context = LocalContext.current
val kind3Follow = CodeName(KIND3_FOLLOWS, ResourceName(R.string.follow_list_kind3follows, context), CodeNameType.HARDCODED)
val globalFollow = CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global, context), CodeNameType.HARDCODED)
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
val followLists by followListsModel.followLists.collectAsState()
val followLists by followListsModel.peopleLists.collectAsState()
val routeList by followListsModel.routes.collectAsState()
val allLists = remember(followLists) {
(defaultOptions + followLists)
if (withRoutes) {
(defaultOptions + followLists + routeList)
} else {
(defaultOptions + followLists)
}
}
val followNames by remember(followLists) {
derivedStateOf {
allLists.map { it.second }.toImmutableList()
allLists.map { it.name }.toImmutableList()
}
}
SimpleTextSpinner(
placeholder = allLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
placeholder = allLists.firstOrNull { it.code == listName }?.name?.name() ?: "Select an Option",
options = followNames,
onSelect = {
onChange(allLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
onChange(allLists.getOrNull(it) ?: kind3Follow)
}
)
}
enum class CodeNameType {
HARDCODED, PEOPLE_LIST, ROUTE
}
abstract class Name {
abstract fun name(): String
}
class GeoHashName(val geoHashTag: String) : Name() {
override fun name() = "/g/$geoHashTag"
}
class HashtagName(val hashTag: String) : Name() {
override fun name() = "#$hashTag"
}
class ResourceName(val resourceId: Int, val context: Context) : Name() {
override fun name() = context.getString(resourceId)
}
class PeopleListName(val note: AddressableNote) : Name() {
override fun name() = note.dTag() ?: ""
}
class CommunityName(val note: AddressableNote) : Name() {
override fun name() = "/n/${(note.dTag() ?: "")}"
}
@Immutable
data class CodeName(val code: String, val name: Name, val type: CodeNameType)
@Stable
class FollowListViewModel(val account: Account) : ViewModel() {
private var _followLists = MutableStateFlow<ImmutableList<Pair<String, String>>>(emptyList<Pair<String, String>>().toPersistentList())
val followLists = _followLists.asStateFlow()
private var _peopleLists = MutableStateFlow<ImmutableList<CodeName>>(emptyList<CodeName>().toPersistentList())
val peopleLists = _peopleLists.asStateFlow()
private var _routes = MutableStateFlow<ImmutableList<CodeName>>(emptyList<CodeName>().toPersistentList())
val routes = _routes.asStateFlow()
fun refresh() {
viewModelScope.launch(Dispatchers.Default) {
@@ -571,14 +628,34 @@ class FollowListViewModel(val account: Account) : ViewModel() {
event.pubKey == account.userProfile().pubkeyHex &&
(event.tags.size > 1 || event.content.length > 50)
) {
Pair(event.dTag(), event.dTag())
CodeName(event.address().toTag(), PeopleListName(it.value), CodeNameType.PEOPLE_LIST)
} else {
null
}
}.sortedBy { it.second }.toImmutableList()
}.sortedBy { it.name.name() }.toImmutableList()
if (!equalImmutableLists(_followLists.value, newFollowLists)) {
_followLists.emit(newFollowLists)
if (!equalImmutableLists(_peopleLists.value, newFollowLists)) {
_peopleLists.emit(newFollowLists)
}
val communities = account.userProfile().cachedFollowingCommunitiesSet().mapNotNull {
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
CodeName("Community/${communityNote.idHex}", CommunityName(communityNote), CodeNameType.ROUTE)
}
}
val hashtags = account.userProfile().cachedFollowingTagSet().map {
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE)
}
val geotags = account.userProfile().cachedFollowingGeohashSet().map {
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE)
}
val routeList = (communities + hashtags + geotags).sortedBy { it.name.name() }.toImmutableList()
if (!equalImmutableLists(_routes.value, routeList)) {
_routes.emit(routeList)
}
}
@@ -590,7 +667,10 @@ class FollowListViewModel(val account: Account) : ViewModel() {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
if (newNotes.any { it.event is PeopleListEvent }) {
if (newNotes.any {
it.event?.pubKey() == account.userProfile().pubkeyHex && (it.event is PeopleListEvent || it.event is ContactListEvent)
}
) {
refresh()
}
}
@@ -612,8 +692,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
@Composable
fun SimpleTextSpinner(
placeholder: String,
options: ImmutableList<String>,
explainers: ImmutableList<String>? = null,
options: ImmutableList<Name>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -649,10 +728,68 @@ fun SimpleTextSpinner(
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
SpinnerSelectionDialog(
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
currentText = options[it].name()
optionsShowing = false
onSelect(it)
}
) {
RenderOption(it)
}
}
}
}
@Composable
fun RenderOption(it: Name) {
when (it) {
is GeoHashName -> {
LoadCityName(it.geoHashTag) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = "/g/$it", color = MaterialTheme.colors.onSurface)
}
}
}
is HashtagName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = it.name(), color = MaterialTheme.colors.onSurface)
}
}
is ResourceName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = it.resourceId), color = MaterialTheme.colors.onSurface)
}
}
is PeopleListName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = it.name(), color = MaterialTheme.colors.onSurface)
}
}
is CommunityName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
val name by it.note.live().metadata.map {
"/n/" + (it.note as? AddressableNote)?.dTag()
}.observeAsState()
Text(text = name ?: "", color = MaterialTheme.colors.onSurface)
}
}
}
@@ -257,7 +257,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
LoadStatuses(user = baseAccountUser) { statuses ->
LoadStatuses(user = baseAccountUser, accountViewModel) { statuses ->
if (statuses.isEmpty()) {
val currentStatus = remember {
mutableStateOf("")
@@ -17,6 +17,7 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -36,6 +37,8 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AddBountyAmountViewModel : ViewModel() {
private var account: Account? = null
@@ -80,6 +83,7 @@ class AddBountyAmountViewModel : ViewModel() {
fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onClose: () -> Unit) {
val postViewModel: AddBountyAmountViewModel = viewModel()
postViewModel.load(accountViewModel.account, bounty)
val scope = rememberCoroutineScope()
Dialog(
onDismissRequest = { onClose() },
@@ -89,7 +93,11 @@ fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onCl
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp).width(IntrinsicSize.Min)) {
Column(
modifier = Modifier
.padding(10.dp)
.width(IntrinsicSize.Min)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -102,8 +110,10 @@ fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onCl
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
scope.launch(Dispatchers.IO) {
postViewModel.sendPost()
onClose()
}
},
isActive = postViewModel.hasChanged()
)
@@ -646,7 +646,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
fun RenderChannelThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteEvent = baseNote.event as? ChannelCreateEvent ?: return
LoadChannel(baseChannelHex = baseNote.idHex) {
LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) {
RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav)
}
}
@@ -47,7 +47,6 @@ import androidx.lifecycle.map
import com.patrykandpatrick.vico.core.extension.forEachIndexedExtended
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -99,7 +98,7 @@ fun ChatroomComposeChannelOrUser(
}
if (channelHex != null) {
ChatroomChannel(channelHex, baseNote, accountViewModel, nav)
ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav)
} else {
ChatroomPrivateMessages(baseNote, accountViewModel, nav)
}
@@ -134,12 +133,12 @@ private fun ChatroomPrivateMessages(
@Composable
private fun ChatroomChannel(
channelHex: HexKey?,
channelHex: HexKey,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadChannel(baseChannelHex = channelHex!!) { channel ->
LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel ->
ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
}
}
@@ -256,11 +255,10 @@ private fun UserRoomCompose(
note.createdAt()
}
}
val content by remember(note) {
derivedStateOf {
mutableStateOf(
accountViewModel.decrypt(note)
}
)
}
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
@@ -278,7 +276,7 @@ private fun UserRoomCompose(
)
},
channelTitle = {
RoomNameDisplay(room, it, accountViewModel.userProfile())
RoomNameDisplay(room, it, accountViewModel)
},
channelLastTime = createAt,
channelLastContent = content,
@@ -288,20 +286,20 @@ private fun UserRoomCompose(
}
@Composable
fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, loggedInUser: User) {
val roomSubject by loggedInUser.live().messages.map {
fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, accountViewModel: AccountViewModel) {
val roomSubject by accountViewModel.userProfile().live().messages.map {
it.user.privateChatrooms[room]?.subject
}.distinctUntilChanged().observeAsState(loggedInUser.privateChatrooms[room]?.subject)
}.distinctUntilChanged().observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
Crossfade(targetState = roomSubject, modifier) {
if (it != null && it.isNotBlank()) {
if (room.users.size > 1) {
DisplayRoomSubject(it)
} else {
DisplayUserAndSubject(room.users.first(), it)
DisplayUserAndSubject(room.users.first(), it, accountViewModel)
}
} else {
DisplayUserSetAsSubject(room)
DisplayUserSetAsSubject(room, accountViewModel)
}
}
}
@@ -309,7 +307,8 @@ fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, loggedInUser: User) {
@Composable
private fun DisplayUserAndSubject(
user: HexKey,
subject: String
subject: String,
accountViewModel: AccountViewModel
) {
Row() {
Text(
@@ -323,7 +322,7 @@ private fun DisplayUserAndSubject(
fontWeight = FontWeight.Bold,
maxLines = 1
)
LoadUser(baseUserHex = user) {
LoadUser(baseUserHex = user, accountViewModel = accountViewModel) {
it?.let {
UsernameDisplay(it, Modifier.weight(1f))
}
@@ -334,6 +333,7 @@ private fun DisplayUserAndSubject(
@Composable
fun DisplayUserSetAsSubject(
room: ChatroomKey,
accountViewModel: AccountViewModel,
fontWeight: FontWeight = FontWeight.Bold
) {
val userList = remember(room) {
@@ -343,7 +343,7 @@ fun DisplayUserSetAsSubject(
if (userList.size == 1) {
// Regular Design
Row() {
LoadUser(baseUserHex = userList[0]) {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight)
}
@@ -352,7 +352,7 @@ fun DisplayUserSetAsSubject(
} else {
Row() {
userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value ->
LoadUser(baseUserHex = value) {
LoadUser(baseUserHex = value, accountViewModel) {
it?.let {
ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight)
}
@@ -417,15 +417,14 @@ private fun WatchNotificationChanges(
}
@Composable
fun LoadUser(baseUserHex: String, content: @Composable (User?) -> Unit) {
fun LoadUser(baseUserHex: String, accountViewModel: AccountViewModel, content: @Composable (User?) -> Unit) {
var user by remember(baseUserHex) {
mutableStateOf(LocalCache.getUserIfExists(baseUserHex))
mutableStateOf(accountViewModel.getUserIfExists(baseUserHex))
}
if (user == null) {
LaunchedEffect(key1 = baseUserHex) {
launch(Dispatchers.IO) {
val newUser = LocalCache.checkGetOrCreateUser(baseUserHex)
accountViewModel.checkGetOrCreateUser(baseUserHex) { newUser ->
if (user != newUser) {
user = newUser
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -66,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
@@ -285,7 +287,7 @@ fun NormalChatNote(
modifier = modif,
horizontalArrangement = alignment
) {
val availableBubbleSize = remember { mutableStateOf(0) }
val availableBubbleSize = remember { mutableIntStateOf(0) }
var popupExpanded by remember { mutableStateOf(false) }
val modif2 = remember {
@@ -355,7 +357,7 @@ private fun RenderBubble(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val bubbleSize = remember { mutableStateOf(0) }
val bubbleSize = remember { mutableIntStateOf(0) }
val bubbleModifier = remember {
Modifier
@@ -626,8 +628,8 @@ private fun RenderRegularTextNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val eventContent = remember { accountViewModel.decrypt(note) }
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val eventContent by remember { mutableStateOf(accountViewModel.decrypt(note)) }
val modifier = remember { Modifier.padding(top = 5.dp) }
if (eventContent != null) {
@@ -636,7 +638,7 @@ private fun RenderRegularTextNote(
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
content = eventContent,
content = eventContent!!,
canPreview = canPreview,
modifier = modifier,
tags = tags,
@@ -30,6 +30,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -67,8 +68,9 @@ import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.EmptyTagList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -169,7 +171,6 @@ private fun Galeries(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
val likeEvents by remember { derivedStateOf { multiSetCard.likeEventsByType } }
@@ -178,6 +179,16 @@ private fun Galeries(
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
if (hasZapEvents) {
var zapEvents by remember(multiSetCard.zapEvents) {
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
}
LaunchedEffect(key1 = Unit) {
accountViewModel.decryptAmountMessageInGroup(multiSetCard.zapEvents) {
zapEvents = it
}
}
val (value, elapsed) = measureTimedValue {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
}
@@ -247,7 +258,7 @@ fun RenderLikeGallery(
@Composable
fun RenderZapGallery(
zapEvents: ImmutableList<CombinedZap>,
zapEvents: ImmutableList<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
@@ -294,10 +305,29 @@ fun RenderBoostGallery(
}
}
@Composable
fun MapZaps(
zaps: ImmutableList<CombinedZap>,
accountViewModel: AccountViewModel,
content: @Composable (ImmutableList<ZapAmountCommentNotification>) -> Unit
) {
var zapEvents by remember(zaps) {
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
}
LaunchedEffect(key1 = Unit) {
accountViewModel.decryptAmountMessageInGroup(zaps) {
zapEvents = it
}
}
content(zapEvents)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthorGalleryZaps(
authorNotes: ImmutableList<CombinedZap>,
authorNotes: ImmutableList<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
@@ -305,24 +335,12 @@ fun AuthorGalleryZaps(
Column(modifier = StdStartPadding) {
FlowRow() {
authorNotes.forEach {
ParseAuthorCommentAndAmount(it, backgroundColor, nav, accountViewModel)
RenderState(it, backgroundColor, accountViewModel, nav)
}
}
}
}
@Composable
private fun ParseAuthorCommentAndAmount(
zap: CombinedZap,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
ParseAuthorCommentAndAmount(zap.request, zap.response, accountViewModel) { state ->
RenderState(state, backgroundColor, accountViewModel, nav)
}
}
@Immutable
data class ZapAmountCommentNotification(
val user: User?,
@@ -358,19 +376,21 @@ private fun ParseAuthorCommentAndAmount(
onReady(content)
}
fun click(content: ZapAmountCommentNotification, nav: (String) -> Unit) {
content.user?.let {
nav(routeFor(it))
}
}
@Composable
private fun RenderState(
content: MutableState<ZapAmountCommentNotification>,
content: ZapAmountCommentNotification,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Row(
modifier = Modifier.clickable {
content.value.user?.let {
nav(routeFor(it))
}
},
modifier = Modifier.clickable { click(content, nav) },
verticalAlignment = Alignment.CenterVertically
) {
DisplayAuthorCommentAndAmount(
@@ -396,76 +416,65 @@ val commentTextSize = 12.sp
@Composable
private fun DisplayAuthorCommentAndAmount(
authorComment: MutableState<ZapAmountCommentNotification>,
authorComment: ZapAmountCommentNotification,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) {
CrossfadeToDisplayPicture(authorComment, accountViewModel)
CrossfadeToDisplayAmount(authorComment)
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(authorComment.user, accountViewModel)
authorComment.amount?.let {
CrossfadeToDisplayAmount(it)
}
}
CrossfadeToDisplayComment(authorComment, backgroundColor, nav, accountViewModel)
}
@Composable
fun CrossfadeToDisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
Crossfade(authorComment.value) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(it.user, accountViewModel)
authorComment.comment?.let {
CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel)
}
}
@Composable
fun CrossfadeToDisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
Crossfade(authorComment.value, modifier = amountBoxModifier) {
it.amount?.let {
Box(
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
) {
val backgroundColor = MaterialTheme.colors.overPictureBackground
Box(
modifier = remember {
Modifier
.width(Size35dp)
.background(backgroundColor)
},
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.bitcoinColor,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
}
}
fun CrossfadeToDisplayAmount(amount: String) {
Box(
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
) {
val backgroundColor = MaterialTheme.colors.overPictureBackground
Box(
modifier = remember {
Modifier
.width(Size35dp)
.background(backgroundColor)
},
contentAlignment = Alignment.BottomCenter
) {
Text(
text = amount,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.bitcoinColor,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
}
}
}
@Composable
fun CrossfadeToDisplayComment(
authorComment: MutableState<ZapAmountCommentNotification>,
comment: String,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Crossfade(authorComment.value) {
it.comment?.let {
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { ImmutableListOfLists() },
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
}
TranslatableRichTextViewer(
content = comment,
canPreview = true,
tags = EmptyTagList,
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
@OptIn(ExperimentalLayoutApi::class)
@@ -517,25 +526,25 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
accountViewModel: AccountViewModel
) {
WatchUserMetadata(author) { baseUserPicture ->
Crossfade(targetState = baseUserPicture) { userPicture ->
RobohashAsyncImageProxy(
robot = author.pubkeyHex,
model = userPicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = MaterialTheme.colors.profile35dpModifier,
contentScale = ContentScale.Crop
)
}
// Crossfade(targetState = baseUserPicture) { userPicture ->
RobohashAsyncImageProxy(
robot = author.pubkeyHex,
model = baseUserPicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = MaterialTheme.colors.profile35dpModifier,
contentScale = ContentScale.Crop
)
// }
}
WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing ->
Crossfade(targetState = isFollowing) {
if (it) {
Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) {
FollowingIcon(Size10dp)
}
// Crossfade(targetState = isFollowing) {
if (isFollowing) {
Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) {
FollowingIcon(Size10dp)
}
}
// }
}
}
@@ -106,7 +106,7 @@ fun ObserveDisplayNip05Status(
) {
val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05())
LoadStatuses(baseUser) { statuses ->
LoadStatuses(baseUser, accountViewModel) { statuses ->
Crossfade(targetState = nip05, modifier = columnModifier, label = "ObserveDisplayNip05StatusCrossfade") {
VerifyAndDisplayNIP05OrStatusLine(it, statuses, baseUser, columnModifier, accountViewModel, nav)
}
@@ -233,7 +233,7 @@ fun DisplayStatus(
)
}
} else if (nostrATag != null) {
LoadAddressableNote(nostrATag) { note ->
LoadAddressableNote(nostrATag, accountViewModel) { note ->
if (note != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
@@ -255,7 +255,7 @@ fun DisplayStatus(
}
}
} else if (nostrHexID != null) {
LoadNote(baseNoteHex = nostrHexID) {
LoadNote(baseNoteHex = nostrHexID, accountViewModel) {
if (it != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
@@ -31,11 +31,15 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.darkColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -50,6 +54,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Alignment.Companion.TopStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
@@ -80,7 +85,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.User
@@ -117,11 +121,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
@@ -129,11 +135,12 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@@ -146,13 +153,13 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyBackground
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.repostProfileBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.toNpub
@@ -173,11 +180,12 @@ import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.EmojiUrl
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
@@ -624,7 +632,7 @@ fun LongCommunityHeader(
TranslatableRichTextViewer(
content = summary ?: stringResource(id = R.string.community_no_descriptor),
canPreview = false,
tags = remember { ImmutableListOfLists(emptyList()) },
tags = EmptyTagList,
backgroundColor = background,
accountViewModel = accountViewModel,
nav = nav
@@ -666,14 +674,13 @@ fun LongCommunityHeader(
}
LaunchedEffect(key1 = noteState) {
launch(Dispatchers.IO) {
val noteEvent = (noteState?.note?.event as? CommunityDefinitionEvent)
val newParticipantUsers = noteEvent?.moderators()?.mapNotNull { part ->
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
}?.toImmutableList()
val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators()
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
if (participants != null) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
}
@@ -1035,7 +1042,9 @@ private fun NoteBody(
)
}
Spacer(modifier = Modifier.height(3.dp))
if (baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) {
Spacer(modifier = Modifier.height(3.dp))
}
if (!makeItShort) {
ReplyRow(
@@ -1055,6 +1064,62 @@ private fun NoteBody(
accountViewModel,
nav
)
val noteEvent = baseNote.event
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
if (zapSplits && noteEvent != null) {
Spacer(modifier = HalfDoubleVertSpacer)
DisplayZapSplits(noteEvent, accountViewModel, nav)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayZapSplits(noteEvent: EventInterface, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val list = remember(noteEvent) { noteEvent.zapSplitSetup() }
if (list.isEmpty()) return
Row(verticalAlignment = CenterVertically) {
Box(
Modifier
.height(20.dp)
.width(25.dp)
) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange
)
Icon(
imageVector = Icons.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange
)
}
Spacer(modifier = StdHorzSpacer)
FlowRow {
list.forEach {
if (it.isLnAddress) {
ClickableText(
text = AnnotatedString(it.lnAddressOrPubKeyHex),
onClick = { },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {
UserPicture(userHex = it.lnAddressOrPubKeyHex, size = 25.dp, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
}
@Composable
@@ -1279,7 +1344,7 @@ fun RenderTextEvent(
accountViewModel = accountViewModel
) {
val modifier = remember(note) { Modifier.fillMaxWidth() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
TranslatableRichTextViewer(
content = eventContent,
@@ -1318,7 +1383,7 @@ fun RenderPoll(
overflow = TextOverflow.Ellipsis
)
} else {
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
SensitivityWarning(
note = note,
@@ -1493,7 +1558,7 @@ fun RenderAppDefinition(
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) {
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val bgColor = MaterialTheme.colors.background
val backgroundColor = remember {
mutableStateOf(bgColor)
@@ -1560,20 +1625,18 @@ private fun RenderPrivateMessage(
val noteEvent = note.event as? PrivateDmEvent ?: return
val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
if (withMe) {
val eventContent = remember { accountViewModel.decrypt(note) }
val eventContent by remember { mutableStateOf(accountViewModel.decrypt(note)) }
val hashtags = remember(note.event?.id()) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
if (eventContent != null) {
if (makeItShort && isAuthorTheLoggedUser) {
Text(
text = eventContent,
text = eventContent!!,
color = MaterialTheme.colors.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis
@@ -1584,7 +1647,7 @@ private fun RenderPrivateMessage(
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
content = eventContent,
content = eventContent!!,
canPreview = canPreview && !makeItShort,
modifier = modifier,
tags = tags,
@@ -1594,7 +1657,7 @@ private fun RenderPrivateMessage(
)
}
DisplayUncitedHashtags(hashtags, eventContent, nav)
DisplayUncitedHashtags(hashtags, eventContent!!, nav)
}
}
} else {
@@ -1608,7 +1671,7 @@ private fun RenderPrivateMessage(
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
ImmutableListOfLists(),
EmptyTagList,
backgroundColor,
accountViewModel,
nav
@@ -1753,9 +1816,8 @@ fun DisplayPeopleList(
) {
val noteEvent = baseNote.event as? PeopleListEvent ?: return
var members by remember { mutableStateOf<List<User>>(listOf()) }
var members by remember { mutableStateOf<ImmutableList<User>>(persistentListOf()) }
val account = accountViewModel.userProfile()
var expanded by remember {
mutableStateOf(false)
}
@@ -1784,10 +1846,8 @@ fun DisplayPeopleList(
)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
members = noteEvent.bookmarkedPeople().mapNotNull { hex ->
LocalCache.checkGetOrCreateUser(hex)
}.sortedBy { account.isFollowing(it) }.reversed()
accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) {
members = it
}
}
@@ -1835,15 +1895,11 @@ private fun RenderBadgeAward(
val noteEvent = note.event as? BadgeAwardEvent ?: return
var awardees by remember { mutableStateOf<List<User>>(listOf()) }
val account = accountViewModel.userProfile()
Text(text = stringResource(R.string.award_granted_to))
LaunchedEffect(key1 = note) {
launch(Dispatchers.IO) {
awardees = noteEvent.awardees().mapNotNull { hex ->
LocalCache.checkGetOrCreateUser(hex)
}.sortedBy { account.isFollowing(it) }.reversed()
accountViewModel.loadUsers(noteEvent.awardees()) {
awardees = it
}
}
@@ -1952,7 +2008,7 @@ fun RenderPostApproval(
Column(Modifier.fillMaxWidth()) {
noteEvent.communities().forEach {
LoadAddressableNote(it) {
LoadAddressableNote(it, accountViewModel) {
it?.let {
NoteCompose(
it,
@@ -1991,15 +2047,14 @@ fun RenderPostApproval(
}
@Composable
fun LoadAddressableNote(aTagHex: String, content: @Composable (AddressableNote?) -> Unit) {
fun LoadAddressableNote(aTagHex: String, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit) {
var note by remember(aTagHex) {
mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTagHex))
mutableStateOf<AddressableNote?>(accountViewModel.getAddressableNoteIfExists(aTagHex))
}
if (note == null) {
LaunchedEffect(key1 = aTagHex) {
launch(Dispatchers.IO) {
val newNote = LocalCache.checkGetOrCreateAddressableNote(aTagHex)
accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote ->
if (newNote != note) {
note = newNote
}
@@ -2011,15 +2066,14 @@ fun LoadAddressableNote(aTagHex: String, content: @Composable (AddressableNote?)
}
@Composable
fun LoadAddressableNote(aTag: ATag, content: @Composable (AddressableNote?) -> Unit) {
fun LoadAddressableNote(aTag: ATag, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit) {
var note by remember(aTag) {
mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTag.toTag()))
mutableStateOf<AddressableNote?>(accountViewModel.getAddressableNoteIfExists(aTag.toTag()))
}
if (note == null) {
LaunchedEffect(key1 = aTag) {
launch(Dispatchers.IO) {
val newNote = LocalCache.getOrCreateAddressableNote(aTag)
accountViewModel.getOrCreateAddressableNote(aTag) { newNote ->
if (newNote != note) {
note = newNote
}
@@ -2151,7 +2205,8 @@ private fun EmojiListOptions(
accountViewModel.userProfile().pubkeyHex,
"",
null
)
),
accountViewModel
) {
it?.let { usersEmojiList ->
val hasAddedThis by usersEmojiList.live().metadata.map {
@@ -2254,7 +2309,7 @@ fun RenderPinListEvent(
TranslatableRichTextViewer(
content = pin,
canPreview = true,
tags = remember { ImmutableListOfLists() },
tags = EmptyTagList,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -2353,8 +2408,8 @@ private fun RenderReport(
TranslatableRichTextViewer(
content = content,
canPreview = true,
modifier = remember { Modifier },
tags = remember { ImmutableListOfLists() },
modifier = Modifier,
tags = EmptyTagList,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -2503,6 +2558,7 @@ fun SecondUserInfoRow(
@Composable
fun LoadStatuses(
user: User,
accountViewModel: AccountViewModel,
content: @Composable (ImmutableList<AddressableNote>) -> Unit
) {
var statuses: ImmutableList<AddressableNote> by remember {
@@ -2512,9 +2568,7 @@ fun LoadStatuses(
val userStatus by user.live().statuses.observeAsState()
LaunchedEffect(key1 = userStatus) {
launch(Dispatchers.IO) {
val myUser = userStatus?.user ?: return@launch
val newStatuses = LocalCache.findStatusesForUser(myUser)
accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses ->
if (!equalImmutableLists(statuses, newStatuses)) {
statuses = newStatuses
}
@@ -2525,7 +2579,7 @@ fun LoadStatuses(
}
@Composable
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
fun LoadCityName(geohash: String, content: @Composable (String) -> Unit) {
val context = LocalContext.current
var cityName by remember(geohash) {
mutableStateOf<String>(geohash)
@@ -2540,18 +2594,25 @@ fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
}
}
ClickableText(
text = AnnotatedString(cityName),
onClick = { nav("Geohash/$geohash") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
content(cityName)
}
@Composable
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
LoadCityName(geohash) { cityName ->
ClickableText(
text = AnnotatedString(cityName),
onClick = { nav("Geohash/$geohash") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
),
fontSize = Font14SP,
fontWeight = FontWeight.Bold
),
fontSize = Font14SP,
fontWeight = FontWeight.Bold
),
maxLines = 1
)
maxLines = 1
)
}
}
@Composable
@@ -2574,12 +2635,14 @@ fun FirstUserInfoRow(
}
}
val textColor = if (isRepost) MaterialTheme.colors.grayText else Color.Unspecified
if (showAuthorPicture) {
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
Spacer(HalfPadding)
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor)
} else {
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor)
}
if (isRepost) {
@@ -2691,12 +2754,18 @@ private fun RenderAuthorImages(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp)
val baseRepost by remember {
derivedStateOf {
baseNote.replyTo?.lastOrNull()
}
}
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
if (isRepost) {
RepostNoteAuthorPicture(baseNote, accountViewModel, nav)
if (isRepost && baseRepost != null) {
RepostNoteAuthorPicture(baseNote, baseRepost!!, accountViewModel, nav)
} else {
NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp)
}
val isChannel = baseNote.event is ChannelMessageEvent && baseNote.channelHex() != null
@@ -2704,7 +2773,7 @@ private fun RenderAuthorImages(
if (isChannel) {
val baseChannelHex = remember { baseNote.channelHex() }
if (baseChannelHex != null) {
LoadChannel(baseChannelHex) { channel ->
LoadChannel(baseChannelHex, accountViewModel) { channel ->
ChannelNotePicture(channel)
}
}
@@ -2712,15 +2781,14 @@ private fun RenderAuthorImages(
}
@Composable
fun LoadChannel(baseChannelHex: String, content: @Composable (Channel) -> Unit) {
fun LoadChannel(baseChannelHex: String, accountViewModel: AccountViewModel, content: @Composable (Channel) -> Unit) {
var channel by remember(baseChannelHex) {
mutableStateOf<Channel?>(LocalCache.getChannelIfExists(baseChannelHex))
mutableStateOf<Channel?>(accountViewModel.getChannelIfExists(baseChannelHex))
}
if (channel == null) {
LaunchedEffect(key1 = baseChannelHex) {
launch(Dispatchers.IO) {
val newChannel = LocalCache.checkGetOrCreateChannel(baseChannelHex)
accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel ->
launch(Dispatchers.Main) {
channel = newChannel
}
@@ -2767,23 +2835,30 @@ private fun ChannelNotePicture(baseChannel: Channel) {
@Composable
private fun RepostNoteAuthorPicture(
baseNote: Note,
baseRepost: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val baseRepost by remember {
derivedStateOf {
baseNote.replyTo?.lastOrNull()
}
}
baseRepost?.let {
Box(Size30Modifier) {
Box(modifier = Size55Modifier) {
Box(Size35Modifier.align(Alignment.TopStart)) {
NoteAuthorPicture(
baseNote = it,
baseNote = baseNote,
nav = nav,
accountViewModel = accountViewModel,
size = Size30dp,
pictureModifier = MaterialTheme.colors.repostProfileBorder
size = Size34dp
)
}
Box(Size18Modifier.align(Alignment.BottomStart).padding(1.dp)) {
RepostedIcon(modifier = Size18Modifier, MaterialTheme.colors.placeholderText)
}
Box(Size35Modifier.align(Alignment.BottomEnd)) {
NoteAuthorPicture(
baseNote = baseRepost,
nav = nav,
accountViewModel = accountViewModel,
size = Size34dp
)
}
}
@@ -2813,7 +2888,7 @@ fun DisplayHighlight(
quote,
canPreview = canPreview && !makeItShort,
remember { Modifier.fillMaxWidth() },
remember { ImmutableListOfLists<String>(emptyList()) },
EmptyTagList,
backgroundColor,
accountViewModel,
nav
@@ -2830,15 +2905,12 @@ private fun DisplayQuoteAuthor(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var userBase by remember { mutableStateOf<User?>(LocalCache.getUserIfExists(authorHex)) }
var userBase by remember { mutableStateOf<User?>(accountViewModel.getUserIfExists(authorHex)) }
LaunchedEffect(Unit) {
if (userBase == null) {
launch(Dispatchers.IO) {
val newUserBase = LocalCache.checkGetOrCreateUser(authorHex)
launch(Dispatchers.Main) {
userBase = newUserBase
}
if (userBase == null) {
LaunchedEffect(Unit) {
accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase ->
userBase = newUserBase
}
}
}
@@ -2862,7 +2934,7 @@ private fun DisplayQuoteAuthor(
@Composable
private fun LoadAndDisplayPost(postAddress: ATag, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadAddressableNote(aTag = postAddress) {
LoadAddressableNote(aTag = postAddress, accountViewModel) {
it?.let { note ->
val noteEvent by note.live().metadata.map {
it.note.event
@@ -3308,7 +3380,7 @@ fun FileStorageHeaderDisplay(baseNote: Note, roundedCorner: Boolean, accountView
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
val dataEventId = eventHeader.dataEventId() ?: return
LoadNote(baseNoteHex = dataEventId) { contentNote ->
LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote ->
if (contentNote != null) {
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
}
@@ -3383,8 +3455,8 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A
var participantUsers by remember { mutableStateOf<List<Pair<Participant, User>>>(emptyList()) }
LaunchedEffect(key1 = participants) {
launch(Dispatchers.IO) {
participantUsers = participants.mapNotNull { part -> LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } }
accountViewModel.loadParticipants(participants) {
participantUsers = it
}
}
@@ -3463,7 +3535,7 @@ fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: Accou
val defaultBackground = MaterialTheme.colors.background
val background = remember { mutableStateOf(defaultBackground) }
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val eventContent = remember(note.event) {
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
@@ -3593,11 +3665,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
}
LaunchedEffect(key1 = eventUpdates) {
launch(Dispatchers.IO) {
val newParticipantUsers = participants.mapNotNull { part ->
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
}.toImmutableList()
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
@@ -3678,7 +3746,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountViewModel: AccountViewModel) {
val image = remember(noteEvent) { noteEvent.image() }
val title = remember(noteEvent) { noteEvent.title() }
val summary = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } }
val summary = remember(noteEvent) { noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } }
Row(
modifier = Modifier
@@ -24,6 +24,7 @@ import androidx.compose.ui.window.Popup
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -32,9 +33,12 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
@@ -113,7 +117,7 @@ private fun OptionNote(
nav: (String) -> Unit
) {
val tags = remember(baseNote) {
baseNote.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
baseNote.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList
}
Row(
@@ -283,6 +287,12 @@ fun ZapVote(
}
var wantsToZap by remember { mutableStateOf(false) }
var wantsToPay by remember {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf()
)
}
var zappingProgress by remember { mutableStateOf(0f) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
@@ -298,7 +308,7 @@ fun ZapVote(
// interactionSource = remember { MutableInteractionSource() },
// indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (!accountViewModel.isWriteable()) {
if (!accountViewModel.isWriteable() && !accountViewModel.loggedInWithExternalSigner()) {
scope.launch {
Toast
.makeText(
@@ -362,6 +372,8 @@ fun ZapVote(
zappingProgress = it
}
},
onPayViaIntent = {
},
zapType = accountViewModel.account.defaultZapType
)
} else {
@@ -393,10 +405,19 @@ fun ZapVote(
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
},
onPayViaIntent = {
wantsToPay = it
}
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
wantsToPay = persistentListOf()
}
}
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error),
@@ -463,7 +484,8 @@ fun FilteredZapAmountChoicePopup(
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
) {
val context = LocalContext.current
@@ -502,6 +524,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
onPayViaIntent,
defaultZapType
)
onDismiss()
@@ -526,6 +549,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
onPayViaIntent,
defaultZapType
)
onDismiss()
@@ -78,18 +78,17 @@ import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.TextType
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DarkerGreen
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders
import com.vitorpamplona.amethyst.ui.theme.ReactionRowExpandButton
@@ -107,6 +106,8 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
@@ -133,13 +134,14 @@ fun ReactionsRow(
InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav)
Spacer(modifier = HalfDoubleVertSpacer)
LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel)
if (showReactionDetail && wantsToSeeReactions.value) {
ReactionDetailGallery(baseNote, nav, accountViewModel)
Spacer(modifier = HalfDoubleVertSpacer)
}
Spacer(modifier = HalfDoubleVertSpacer)
}
@Composable
@@ -249,7 +251,6 @@ private fun LoadAndDisplayZapraiser(
}
if (zapraiserAmount > 0) {
Spacer(modifier = Height4dpModifier)
Box(
modifier = remember {
ReactionRowZapraiserSize
@@ -457,8 +458,15 @@ private fun WatchZapAndRenderGallery(
accountViewModel: AccountViewModel
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zapEvents by remember(zapsState) {
derivedStateOf { baseNote.zaps.mapNotNull { it.value?.let { zapEvent -> CombinedZap(it.key, zapEvent) } }.toImmutableList() }
var zapEvents by remember(zapsState) {
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
}
LaunchedEffect(key1 = zapsState) {
accountViewModel.decryptAmountMessageInGroup(baseNote) {
zapEvents = it
}
}
if (zapEvents.isNotEmpty()) {
@@ -543,12 +551,16 @@ fun ReplyReaction(
if (accountViewModel.isWriteable()) {
onPress()
} else {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_reply),
Toast.LENGTH_SHORT
).show()
if (accountViewModel.loggedInWithExternalSigner()) {
onPress()
} else {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_reply),
Toast.LENGTH_SHORT
).show()
}
}
}
}
@@ -652,12 +664,22 @@ fun BoostReaction(
wantsToBoost = true
}
} else {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_boost_posts),
Toast.LENGTH_SHORT
).show()
if (accountViewModel.loggedInWithExternalSigner()) {
if (accountViewModel.hasBoosted(baseNote)) {
scope.launch(Dispatchers.IO) {
accountViewModel.deleteBoostsTo(baseNote)
}
} else {
wantsToBoost = true
}
} else {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_boost_posts),
Toast.LENGTH_SHORT
).show()
}
}
}
}
@@ -675,6 +697,11 @@ fun BoostReaction(
onQuote = {
wantsToBoost = false
onQuotePress()
},
onRepost = {
scope.launch(Dispatchers.IO) {
accountViewModel.boost(baseNote)
}
}
)
}
@@ -738,6 +765,20 @@ fun LikeReaction(
context,
onMultipleChoices = {
wantsToReact = true
},
onWantsToSignReaction = {
if (accountViewModel.account.reactionChoices.size == 1) {
scope.launch(Dispatchers.IO) {
val reaction = accountViewModel.account.reactionChoices.first()
if (accountViewModel.hasReactedTo(baseNote, reaction)) {
accountViewModel.deleteReactionTo(baseNote, reaction)
} else {
accountViewModel.reactTo(baseNote, reaction)
}
}
} else if (accountViewModel.account.reactionChoices.size > 1) {
wantsToReact = true
}
}
)
},
@@ -857,7 +898,8 @@ private fun likeClick(
accountViewModel: AccountViewModel,
scope: CoroutineScope,
context: Context,
onMultipleChoices: () -> Unit
onMultipleChoices: () -> Unit,
onWantsToSignReaction: () -> Unit
) {
if (accountViewModel.account.reactionChoices.isEmpty()) {
scope.launch {
@@ -870,14 +912,18 @@ private fun likeClick(
.show()
}
} else if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_like_posts),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
onWantsToSignReaction()
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_like_posts),
Toast.LENGTH_SHORT
)
.show()
}
}
} else if (accountViewModel.account.reactionChoices.size == 1) {
scope.launch(Dispatchers.IO) {
@@ -907,6 +953,11 @@ fun ZapReaction(
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
var wantsToPay by remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf()
)
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -938,6 +989,9 @@ fun ZapReaction(
zappingProgress = 0f
showErrorMessageDialog = it
}
},
onPayViaIntent = {
wantsToPay = it
}
)
},
@@ -971,6 +1025,9 @@ fun ZapReaction(
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
},
onPayViaIntent = {
wantsToPay = it
}
)
}
@@ -981,7 +1038,10 @@ fun ZapReaction(
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
baseNote.author?.let {
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel))
scope.launch(Dispatchers.IO) {
val route = routeToMessage(it, showErrorMessageDialog, accountViewModel)
nav(route)
}
}
},
onDismiss = { showErrorMessageDialog = null }
@@ -995,6 +1055,12 @@ fun ZapReaction(
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
wantsToPay = persistentListOf()
}
}
if (wantsToSetCustomZap) {
ZapCustomDialog(
onClose = { wantsToSetCustomZap = false },
@@ -1009,6 +1075,9 @@ fun ZapReaction(
zappingProgress = it
}
},
onPayViaIntent = {
wantsToPay = it
},
accountViewModel = accountViewModel,
baseNote = baseNote
)
@@ -1045,7 +1114,8 @@ private fun zapClick(
context: Context,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: () -> Unit,
onError: (String) -> Unit
onError: (String) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
) {
if (accountViewModel.account.zapAmountChoices.isEmpty()) {
scope.launch {
@@ -1057,7 +1127,7 @@ private fun zapClick(
)
.show()
}
} else if (!accountViewModel.isWriteable()) {
} else if (!accountViewModel.isWriteable() && !accountViewModel.loggedInWithExternalSigner()) {
scope.launch {
Toast
.makeText(
@@ -1080,7 +1150,8 @@ private fun zapClick(
onZappingProgress(it)
}
},
zapType = accountViewModel.account.defaultZapType
zapType = accountViewModel.account.defaultZapType,
onPayViaIntent = onPayViaIntent
)
} else if (accountViewModel.account.zapAmountChoices.size > 1) {
onMultipleChoices()
@@ -1196,7 +1267,7 @@ private fun DrawViewCount(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit, onRepost: () -> Unit) {
val iconSizePx = with(LocalDensity.current) {
-iconSize.toPx().toInt()
}
@@ -1211,8 +1282,13 @@ private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel:
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.boost(baseNote)
if (accountViewModel.isWriteable()) {
scope.launch(Dispatchers.IO) {
accountViewModel.boost(baseNote)
onDismiss()
}
} else {
onRepost()
onDismiss()
}
},
@@ -1391,10 +1467,11 @@ fun ZapAmountChoicePopup(
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapMessage = ""
@@ -1417,6 +1494,7 @@ fun ZapAmountChoicePopup(
context,
onError,
onProgress,
onPayViaIntent,
account.defaultZapType
)
onDismiss()
@@ -1441,6 +1519,7 @@ fun ZapAmountChoicePopup(
context,
onError,
onProgress,
onPayViaIntent,
account.defaultZapType
)
onDismiss()
@@ -314,7 +314,8 @@ private fun EmojiSelector(accountViewModel: AccountViewModel, nav: (String) -> U
accountViewModel.userProfile().pubkeyHex,
"",
null
)
),
accountViewModel
) { emptyNote ->
emptyNote?.let { usersEmojiList ->
val collections by usersEmojiList.live().metadata.map {
@@ -339,7 +340,7 @@ fun EmojiCollectionGallery(emojiCollections: List<ATag>, accountViewModel: Accou
state = listState
) {
itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item ->
LoadAddressableNote(aTag = item) {
LoadAddressableNote(aTag = item, accountViewModel) {
it?.let {
WatchAndRenderNote(it, bgColor, accountViewModel, nav, onClick)
}
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
@@ -219,8 +220,7 @@ fun UpdateZapAmountDialog(
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
val zapOptions = remember { zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
LaunchedEffect(accountViewModel, nip47uri) {
postViewModel.load()
@@ -361,7 +361,6 @@ fun UpdateZapAmountDialog(
placeholder = zapTypes.filter { it.first == accountViewModel.defaultZapType() }
.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
postViewModel.selectedZapType = zapTypes[it].first
},
@@ -43,11 +43,13 @@ import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -102,6 +104,32 @@ fun DisplayBlankAuthor(size: Dp, modifier: Modifier = Modifier) {
)
}
@Composable
fun UserPicture(
userHex: String,
size: Dp,
pictureModifier: Modifier = remember { Modifier },
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadUser(baseUserHex = userHex, accountViewModel) {
if (it != null) {
UserPicture(
user = it,
size = size,
pictureModifier = pictureModifier,
accountViewModel = accountViewModel,
nav = nav
)
} else {
DisplayBlankAuthor(
size,
pictureModifier
)
}
}
}
@Composable
fun UserPicture(
user: User,
@@ -188,57 +216,57 @@ fun NonClickableUserPictures(
when (userList.size) {
0 -> {}
1 -> LoadUser(baseUserHex = userList[0]) {
1 -> LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier)
}
}
2 -> {
LoadUser(baseUserHex = userList[0]) {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterStart))
}
}
LoadUser(baseUserHex = userList[1]) {
LoadUser(baseUserHex = userList[1], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterEnd))
}
}
}
3 -> {
LoadUser(baseUserHex = userList[0]) {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
}
}
LoadUser(baseUserHex = userList[1]) {
LoadUser(baseUserHex = userList[1], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.TopCenter))
}
}
LoadUser(baseUserHex = userList[2]) {
LoadUser(baseUserHex = userList[2], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
}
}
}
else -> {
LoadUser(baseUserHex = userList[0]) {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
}
}
LoadUser(baseUserHex = userList[1]) {
LoadUser(baseUserHex = userList[1], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopStart))
}
}
LoadUser(baseUserHex = userList[2]) {
LoadUser(baseUserHex = userList[2], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
}
}
LoadUser(baseUserHex = userList[3]) {
LoadUser(baseUserHex = userList[3], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopEnd))
}
@@ -383,11 +411,15 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
val scope = rememberCoroutineScope()
if (!state.isFollowingAuthor) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
); onDismiss()
}) {
DropdownMenuItem(
onClick = {
val author = note.author ?: return@DropdownMenuItem
scope.launch(Dispatchers.IO) {
accountViewModel.follow(author)
onDismiss()
}
}
) {
Text(stringResource(R.string.follow))
}
Divider()
@@ -438,36 +470,104 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
Text(stringResource(R.string.quick_action_share))
}
Divider()
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePrivateBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
} else {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPrivateBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (state.isPublicBookmarkNote) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePublicBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
} else {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPublicBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.add_to_public_bookmarks))
}
}
Divider()
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.broadcast(note); onDismiss() } }) {
Text(stringResource(R.string.broadcast))
}
Divider()
if (state.isLoggedUser) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) {
Text(stringResource(R.string.request_deletion))
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
if (accountViewModel.loggedInWithExternalSigner()) {
val bookmarks = accountViewModel.userProfile().latestBookmarkList
ExternalSignerUtils.decrypt(
bookmarks?.content ?: "",
accountViewModel.account.keyPair.pubKey.toHexKey(),
bookmarks?.id ?: ""
)
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
accountViewModel.removePrivateBookmark(note, bookmarks?.decryptedContent ?: "")
} else {
accountViewModel.removePrivateBookmark(note)
onDismiss()
}
}
}
) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
} else {
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
Text(stringResource(R.string.block_report))
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
if (accountViewModel.loggedInWithExternalSigner()) {
val bookmarks = accountViewModel.userProfile().latestBookmarkList
ExternalSignerUtils.decrypt(
bookmarks?.content ?: "",
accountViewModel.account.keyPair.pubKey.toHexKey(),
bookmarks?.id ?: ""
)
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
accountViewModel.addPrivateBookmark(note, bookmarks?.decryptedContent ?: "")
} else {
accountViewModel.addPrivateBookmark(note)
onDismiss()
}
}
}
) {
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (state.isPublicBookmarkNote) {
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
if (accountViewModel.loggedInWithExternalSigner()) {
val bookmarks = accountViewModel.userProfile().latestBookmarkList
ExternalSignerUtils.decrypt(
bookmarks?.content ?: "",
accountViewModel.account.keyPair.pubKey.toHexKey(),
bookmarks?.id ?: ""
)
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
accountViewModel.removePublicBookmark(
note,
bookmarks?.decryptedContent ?: ""
)
} else {
accountViewModel.removePublicBookmark(note)
onDismiss()
}
}
}
) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
} else {
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
if (accountViewModel.loggedInWithExternalSigner()) {
val bookmarks = accountViewModel.userProfile().latestBookmarkList
ExternalSignerUtils.decrypt(
bookmarks?.content ?: "",
accountViewModel.account.keyPair.pubKey.toHexKey(),
bookmarks?.id ?: ""
)
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
accountViewModel.addPublicBookmark(
note,
bookmarks?.decryptedContent ?: ""
)
} else {
accountViewModel.addPublicBookmark(note)
onDismiss()
}
}
}
) {
Text(stringResource(R.string.add_to_public_bookmarks))
}
}
Divider()
@@ -486,6 +586,16 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
Text(stringResource(R.string.content_warning_see_warnings))
}
}
Divider()
if (state.isLoggedUser) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) {
Text(stringResource(R.string.request_deletion))
}
} else {
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
Text(stringResource(R.string.block_report))
}
}
}
if (reportDialogShowing) {
@@ -15,6 +15,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
@@ -31,20 +32,20 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true, textColor: Color = Color.Unspecified) {
val authorState by baseNote.live().metadata.map {
it.note.author
}.observeAsState(baseNote.author)
Crossfade(targetState = authorState, modifier = weight) {
it?.let {
UsernameDisplay(it, weight, showPlayButton)
UsernameDisplay(it, weight, showPlayButton, textColor = textColor)
}
}
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true, fontWeight: FontWeight = FontWeight.Bold) {
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true, fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified) {
val npubDisplay by remember {
derivedStateOf {
baseUser.pubkeyDisplayHex()
@@ -57,9 +58,9 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton:
Crossfade(targetState = userMetadata, modifier = weight) {
if (it != null) {
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight)
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight, textColor)
} else {
NPubDisplay(npubDisplay, weight, fontWeight)
NPubDisplay(npubDisplay, weight, fontWeight, textColor)
}
}
}
@@ -72,27 +73,29 @@ private fun UserNameDisplay(
tags: ImmutableListOfLists<String>?,
modifier: Modifier,
showPlayButton: Boolean = true,
fontWeight: FontWeight = FontWeight.Bold
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified
) {
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
UserAndUsernameDisplay(bestDisplayName.trim(), tags, bestUserName.trim(), modifier, showPlayButton, fontWeight)
UserAndUsernameDisplay(bestDisplayName.trim(), tags, bestUserName.trim(), modifier, showPlayButton, fontWeight, textColor)
} else if (bestDisplayName != null) {
UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight)
UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight, textColor)
} else if (bestUserName != null) {
UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight)
UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight, textColor)
} else {
NPubDisplay(npubDisplay, modifier, fontWeight)
NPubDisplay(npubDisplay, modifier, fontWeight, textColor)
}
}
@Composable
fun NPubDisplay(npubDisplay: String, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold) {
fun NPubDisplay(npubDisplay: String, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified) {
Text(
text = npubDisplay,
fontWeight = fontWeight,
modifier = modifier,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
color = textColor
)
}
@@ -102,7 +105,8 @@ private fun UserDisplay(
tags: ImmutableListOfLists<String>?,
modifier: Modifier,
showPlayButton: Boolean = true,
fontWeight: FontWeight = FontWeight.Bold
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
@@ -111,7 +115,8 @@ private fun UserDisplay(
fontWeight = fontWeight,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
modifier = modifier,
color = textColor
)
if (showPlayButton) {
Spacer(StdHorzSpacer)
@@ -127,7 +132,8 @@ private fun UserAndUsernameDisplay(
bestUserName: String,
modifier: Modifier,
showPlayButton: Boolean = true,
fontWeight: FontWeight = FontWeight.Bold
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
@@ -135,7 +141,8 @@ private fun UserAndUsernameDisplay(
tags = tags,
fontWeight = fontWeight,
maxLines = 1,
modifier = modifier
modifier = modifier,
color = textColor
)
/*
CreateTextWithEmoji(
@@ -1,34 +1,62 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.ButtonColors
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
class ZapOptionstViewModel : ViewModel() {
@@ -62,6 +90,7 @@ fun ZapCustomDialog(
onClose: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
accountViewModel: AccountViewModel,
baseNote: Note
) {
@@ -79,8 +108,7 @@ fun ZapCustomDialog(
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
val zapOptions = remember { zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
var selectedZapType by remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) }
Dialog(
@@ -113,9 +141,9 @@ fun ZapCustomDialog(
context,
onError = onError,
onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
zapType = selectedZapType
)
onClose()
}
}
@@ -153,7 +181,6 @@ fun ZapCustomDialog(
label = stringResource(id = R.string.zap_type),
placeholder = zapTypes.filter { it.first == accountViewModel.account.defaultZapType }.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
selectedZapType = zapTypes[it].first
},
@@ -266,3 +293,115 @@ fun ErrorMessageDialog(
}
)
}
@Composable
fun PayViaIntentDialog(
payingInvoices: ImmutableList<ZapPaymentHandler.Payable>,
accountViewModel: AccountViewModel,
onClose: () -> Unit
) {
val context = LocalContext.current
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onPress = onClose)
}
Spacer(modifier = DoubleVertSpacer)
payingInvoices.forEachIndexed { index, it ->
val paid = remember {
mutableStateOf(false)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
if (it.user != null) {
BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel)
} else {
DisplayBlankAuthor(size = Size55dp)
}
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
if (it.user != null) {
UsernameDisplay(it.user, showPlayButton = false)
} else {
Text(
text = stringResource(id = R.string.wallet_number, index + 1),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row() {
Text(
text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Spacer(modifier = StdHorzSpacer)
Text(
text = stringResource(id = R.string.sats),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
}
Spacer(modifier = DoubleHorzSpacer)
PayButton(isActive = !paid.value) {
paid.value = true
val uri = "lightning:" + it.invoice
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
}
}
}
}
}
}
}
@Composable
fun PayButton(isActive: Boolean, modifier: Modifier = Modifier, onPost: () -> Unit = {}) {
Button(
modifier = modifier,
onClick = {
onPost()
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
),
contentPadding = PaddingValues(0.dp)
) {
if (isActive) {
Text(text = stringResource(R.string.pay), color = Color.White)
} else {
Text(text = stringResource(R.string.paid), color = Color.White)
}
}
}
@@ -26,7 +26,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
@@ -40,7 +39,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -53,16 +51,9 @@ fun ZapNoteCompose(baseReqResponse: ZapReqResponse, accountViewModel: AccountVie
}
LaunchedEffect(baseNoteRequest) {
launch(Dispatchers.Default) {
(baseNoteRequest?.note?.event as? LnZapRequestEvent)?.let {
baseNoteRequest?.note?.let {
val decryptedContent = accountViewModel.decryptZap(it)
if (decryptedContent != null) {
baseAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
} else {
baseAuthor = it.author
}
}
baseNoteRequest?.note?.let {
accountViewModel.decryptAmountMessage(it, baseReqResponse.zapEvent) {
baseAuthor = it?.user
}
}
}
@@ -173,6 +164,8 @@ fun UserActionOptions(
baseAuthor: User,
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
WatchIsHiddenUser(baseAuthor, accountViewModel) { isHidden ->
if (isHidden) {
ShowUserButton {
@@ -209,14 +202,20 @@ fun ShowFollowingOrUnfollowingButton(
if (isFollowing) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.unfollow(baseAuthor)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
@@ -227,14 +226,20 @@ fun ShowFollowingOrUnfollowingButton(
} else {
FollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.follow(baseAuthor)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
@@ -5,13 +5,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -24,8 +25,10 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -84,11 +87,33 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
val zapEvents by remember { derivedStateOf { zapSetCard.zapEvents } }
AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel)
Column(modifier = Modifier) {
Row(Modifier.fillMaxWidth()) {
MapZaps(zapSetCard.zapEvents, accountViewModel) {
AuthorGalleryZaps(it, backgroundColor, nav, accountViewModel)
}
}
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, nav = nav)
Spacer(DoubleVertSpacer)
Row(Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
UserPicture(
zapSetCard.user,
Size55dp,
accountViewModel = accountViewModel,
nav = nav
)
Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(zapSetCard.user)
}
AboutDisplay(zapSetCard.user)
}
}
Spacer(DoubleVertSpacer)
}
}
@@ -45,21 +45,21 @@ class AccountStateViewModel(val context: Context) : ViewModel() {
}
}
fun startUI(key: String, useProxy: Boolean, proxyPort: Int) {
fun startUI(key: String, useProxy: Boolean, proxyPort: Int, loginWithExternalSigner: Boolean = false) {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.hexToByteArray()
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val account =
if (key.startsWith("nsec")) {
Account(KeyPair(privKey = key.bechToBytes()), proxy = proxy, proxyPort = proxyPort)
Account(KeyPair(privKey = key.bechToBytes()), proxy = proxy, proxyPort = proxyPort, loginWithExternalSigner = loginWithExternalSigner)
} else if (pubKeyParsed != null) {
Account(KeyPair(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort)
Account(KeyPair(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort, loginWithExternalSigner = loginWithExternalSigner)
} else if (EMAIL_PATTERN.matcher(key).matches()) {
// Evaluate NIP-5
Account(KeyPair(), proxy = proxy, proxyPort = proxyPort)
Account(KeyPair(), proxy = proxy, proxyPort = proxyPort, loginWithExternalSigner = loginWithExternalSigner)
} else {
Account(KeyPair(Hex.decode(key)), proxy = proxy, proxyPort = proxyPort)
Account(KeyPair(Hex.decode(key)), proxy = proxy, proxyPort = proxyPort, loginWithExternalSigner = loginWithExternalSigner)
}
LocalPreferences.updatePrefsForLogin(account)
@@ -34,6 +34,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.measureTimedValue
@Stable
@@ -173,6 +176,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys
val multiCards = allBaseNotes.map { baseNote ->
val boostsInCard = boostsPerEvent[baseNote] ?: emptyList()
@@ -180,26 +185,47 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val zapsInCard = zapsPerEvent[baseNote] ?: emptyList()
val singleList = (boostsInCard + zapsInCard.map { it.response } + reactionsInCard)
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.groupBy {
sdf.format(
Instant.ofEpochSecond(it.createdAt() ?: 0)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
)
}
singleList.chunked(30).map { chunk ->
MultiSetCard(
baseNote,
boostsInCard.filter { it in chunk }.toImmutableList(),
reactionsInCard.filter { it in chunk }.toImmutableList(),
zapsInCard.filter { it.response in chunk }.toImmutableList()
val days = singleList.keys.sortedBy { it }
days.mapNotNull {
val sortedList = singleList.get(it)?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))?.reversed()
sortedList?.chunked(30)?.map { chunk ->
MultiSetCard(
baseNote,
boostsInCard.filter { it in chunk }.toImmutableList(),
reactionsInCard.filter { it in chunk }.toImmutableList(),
zapsInCard.filter { it.response in chunk }.toImmutableList()
)
}
}.flatten()
}.flatten()
val userZaps = zapsPerUser.map { user ->
val byDay = user.value.groupBy {
sdf.format(
Instant.ofEpochSecond(it.createdAt() ?: 0)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
)
}
byDay.values.map {
ZapUserSetCard(
user.key,
it.sortedWith(compareBy({ it.createdAt() }, { it.idHex() })).reversed().toImmutableList()
)
}
}.flatten()
val userZaps = zapsPerUser.map {
ZapUserSetCard(
it.key,
it.value.toImmutableList()
)
}
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is GenericRepostEvent && it.event !is LnZapEvent }.map {
if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) {
MessageSetCard(it)
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -146,7 +147,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item ->
if (index == 0) {
ProvideTextStyle(TextStyle(fontSize = 16.sp, lineHeight = 1.20.em)) {
ProvideTextStyle(TextStyle(fontSize = 18.sp, lineHeight = 1.20.em)) {
NoteMaster(
item,
modifier = Modifier.drawReplyLevel(
@@ -463,6 +464,13 @@ fun NoteMaster(
}
}
val noteEvent = baseNote.event
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
if (zapSplits && noteEvent != null) {
Spacer(modifier = DoubleVertSpacer)
DisplayZapSplits(noteEvent, accountViewModel, nav)
}
ReactionsRow(note, true, accountViewModel, nav)
Divider(
@@ -589,20 +597,20 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) {
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
noteEvent.title()?.let {
Spacer(modifier = DoubleVertSpacer)
Text(
text = it,
fontSize = 28.sp,
fontWeight = FontWeight.Light,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
noteEvent.summary()?.let {
noteEvent.summary()?.ifBlank { null }?.let {
Spacer(modifier = DoubleVertSpacer)
Text(
text = it,
modifier = Modifier.fillMaxWidth(),
@@ -101,10 +101,12 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
private val bundler = BundledUpdate(250, Dispatchers.IO)
override fun invalidateData(ignoreIfDoing: Boolean) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
}
@@ -1,21 +1,18 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -27,32 +24,37 @@ import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.Participant
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.util.Locale
@Stable
class AccountViewModel(val account: Account) : ViewModel() {
class AccountViewModel(val account: Account) : ViewModel(), Dao {
val accountLiveData: LiveData<AccountState> = account.live.map { it }
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
val accountLastReadLiveData: LiveData<AccountState> = account.liveLastRead.map { it }
@@ -102,6 +104,10 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.isWriteable()
}
fun loggedInWithExternalSigner(): Boolean {
return account.loginWithExternalSigner
}
fun userProfile(): User {
return account.userProfile()
}
@@ -176,6 +182,38 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun decryptAmountMessageInGroup(
zaps: ImmutableList<CombinedZap>,
onNewState: (ImmutableList<ZapAmountCommentNotification>) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
val list = ArrayList<ZapAmountCommentNotification>(zaps.size)
zaps.forEach {
innerDecryptAmountMessage(it.request, it.response)?.let {
list.add(it)
}
}
onNewState(list.toImmutableList())
}
}
fun decryptAmountMessageInGroup(
baseNote: Note,
onNewState: (ImmutableList<ZapAmountCommentNotification>) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
val list = ArrayList<ZapAmountCommentNotification>(baseNote.zaps.size)
baseNote.zaps.forEach {
innerDecryptAmountMessage(it.key, it.value)?.let {
list.add(it)
}
}
onNewState(list.toImmutableList())
}
}
fun decryptAmountMessage(
zapRequest: Note,
zapEvent: Note?,
@@ -190,6 +228,8 @@ class AccountViewModel(val account: Account) : ViewModel() {
zapRequest: Note,
zapEvent: Note?
): ZapAmountCommentNotification? {
checkNotInMainThread()
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
@@ -213,78 +253,22 @@ class AccountViewModel(val account: Account) : ViewModel() {
return null
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
fun zap(
note: Note,
amount: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
zapType: LnZapEvent.ZapType
) {
viewModelScope.launch(Dispatchers.IO) {
innerZap(note, amount, pollOption, message, context, onError, onProgress, zapType)
ZapPaymentHandler(account).zap(note, amount, pollOption, message, context, onError, onProgress, onPayViaIntent, zapType)
}
}
private suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
return
}
var zapRequestJson = ""
if (zapType != LnZapEvent.ZapType.NONZAP) {
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType)
if (zapRequest != null) {
zapRequestJson = zapRequest.toJson()
}
}
onProgress(0.10f)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
response.error?.message
?: response.error?.code?.toString()
?: "Error parsing error message"
)
} else {
onProgress(1f)
}
}
)
onProgress(0.8f)
// Awaits for the event to come back to LocalCache.
viewModelScope.launch(Dispatchers.IO) {
delay(5000)
onProgress(0f)
}
} else {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
onError(context.getString(R.string.lightning_wallets_not_found))
}
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress
)
}
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
account.report(note, type, content)
}
@@ -312,10 +296,26 @@ class AccountViewModel(val account: Account) : ViewModel() {
account.addPrivateBookmark(note)
}
fun addPrivateBookmark(note: Note, decryptedContent: String) {
account.addPrivateBookmark(note, decryptedContent)
}
fun addPublicBookmark(note: Note, decryptedContent: String) {
account.addPublicBookmark(note, decryptedContent)
}
fun removePublicBookmark(note: Note, decryptedContent: String) {
account.removePublicBookmark(note, decryptedContent)
}
fun addPublicBookmark(note: Note) {
account.addPublicBookmark(note)
}
fun removePrivateBookmark(note: Note, decryptedContent: String) {
account.removePrivateBookmark(note, decryptedContent)
}
fun removePrivateBookmark(note: Note) {
account.removePrivateBookmark(note)
}
@@ -427,7 +427,7 @@ class AccountViewModel(val account: Account) : ViewModel() {
)
fun isNoteAcceptable(note: Note, onReady: (NoteComposeReportState) -> Unit) {
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
@@ -564,6 +564,117 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun runOnIO(runOnIO: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
runOnIO()
}
}
suspend fun checkGetOrCreateUser(key: HexKey): User? {
return LocalCache.checkGetOrCreateUser(key)
}
override suspend fun getOrCreateUser(key: HexKey): User {
return LocalCache.getOrCreateUser(key)
}
fun checkGetOrCreateUser(key: HexKey, onResult: (User?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(checkGetOrCreateUser(key))
}
}
fun getUserIfExists(hex: HexKey): User? {
return LocalCache.getUserIfExists(hex)
}
suspend fun checkGetOrCreateNote(key: HexKey): Note? {
return LocalCache.checkGetOrCreateNote(key)
}
override suspend fun getOrCreateNote(key: HexKey): Note {
return LocalCache.getOrCreateNote(key)
}
fun checkGetOrCreateNote(key: HexKey, onResult: (Note?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(checkGetOrCreateNote(key))
}
}
fun getNoteIfExists(hex: HexKey): Note? {
return LocalCache.getNoteIfExists(hex)
}
override suspend fun checkGetOrCreateAddressableNote(key: HexKey): AddressableNote? {
return LocalCache.checkGetOrCreateAddressableNote(key)
}
fun checkGetOrCreateAddressableNote(key: HexKey, onResult: (AddressableNote?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(checkGetOrCreateAddressableNote(key))
}
}
suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? {
return LocalCache.getOrCreateAddressableNote(key)
}
fun getOrCreateAddressableNote(key: ATag, onResult: (AddressableNote?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(getOrCreateAddressableNote(key))
}
}
fun getAddressableNoteIfExists(key: String): AddressableNote? {
return LocalCache.addressables[key]
}
fun findStatusesForUser(myUser: User, onResult: (ImmutableList<AddressableNote>) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(LocalCache.findStatusesForUser(myUser))
}
}
suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
return LocalCache.checkGetOrCreateChannel(key)
}
fun checkGetOrCreateChannel(key: HexKey, onResult: (Channel?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(checkGetOrCreateChannel(key))
}
}
fun getChannelIfExists(hex: HexKey): Channel? {
return LocalCache.getChannelIfExists(hex)
}
fun loadParticipants(participants: List<Participant>, onReady: (ImmutableList<Pair<Participant, User>>) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val participantUsers = participants.mapNotNull { part ->
checkGetOrCreateUser(part.key)?.let {
Pair(
part,
it
)
}
}.toImmutableList()
onReady(participantUsers)
}
}
fun loadUsers(hexList: List<String>, onReady: (ImmutableList<User>) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onReady(
hexList.mapNotNull { hex ->
checkGetOrCreateUser(hex)
}.sortedBy { account.isFollowing(it) }.reversed().toImmutableList()
)
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel
@@ -129,7 +129,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.Participant
import com.vitorpamplona.quartz.events.toImmutableListOfLists
@@ -149,7 +149,7 @@ fun ChannelScreen(
) {
if (channelId == null) return
LoadChannel(channelId) {
LoadChannel(channelId, accountViewModel) {
PrepareChannelViewModels(
baseChannel = it,
accountViewModel = accountViewModel,
@@ -169,6 +169,7 @@ fun PrepareChannelViewModels(baseChannel: Channel, accountViewModel: AccountView
)
val channelScreenModel: NewPostViewModel = viewModel()
channelScreenModel.accountViewModel = accountViewModel
channelScreenModel.account = accountViewModel.account
ChannelScreen(
@@ -267,7 +268,8 @@ fun ChannelScreen(
message = newPostModel.message.text,
mentions = listOfNotNull(replyTo.value?.author),
replyTos = listOfNotNull(replyTo.value),
channelHex = channel.idHex
channelHex = channel.idHex,
dao = accountViewModel
)
tagger.run()
if (channel is PublicChatChannel) {
@@ -530,7 +532,7 @@ fun ChannelHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadChannel(channelHex) {
LoadChannel(channelHex, accountViewModel) {
ChannelHeader(
it,
showVideo,
@@ -749,9 +751,9 @@ fun LongChannelHeader(
val tags = remember(channelState) {
if (baseChannel is LiveActivitiesChannel) {
baseChannel.info?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
baseChannel.info?.tags()?.toImmutableListOfLists() ?: EmptyTagList
} else {
ImmutableListOfLists()
EmptyTagList
}
}
@@ -783,7 +785,7 @@ fun LongChannelHeader(
}
}
LoadNote(baseNoteHex = channel.idHex) { loadingNote ->
LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote ->
loadingNote?.let { note ->
Row(
lineModifier,
@@ -886,7 +888,7 @@ private fun ShortChannelActionOptions(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadNote(baseNoteHex = channel.idHex) {
LoadNote(baseNoteHex = channel.idHex, accountViewModel) {
it?.let {
var popupExpanded by remember { mutableStateOf(false) }
@@ -185,6 +185,7 @@ fun PrepareChatroomViewModels(
)
val newPostModel: NewPostViewModel = viewModel()
newPostModel.accountViewModel = accountViewModel
newPostModel.account = accountViewModel.account
newPostModel.requiresNIP24 = room.users.size > 1
if (newPostModel.requiresNIP24) {
@@ -482,7 +483,7 @@ fun ChatroomHeader(
nav: (String) -> Unit
) {
if (room.users.size == 1) {
LoadUser(baseUserHex = room.users.first()) { baseUser ->
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
if (baseUser != null) {
ChatroomHeader(baseUser = baseUser, modifier = modifier, accountViewModel = accountViewModel, nav = nav)
}
@@ -559,7 +560,7 @@ fun GroupChatroomHeader(
Column(modifier = Modifier.padding(start = 10.dp)) {
RoomNameOnlyDisplay(room, Modifier, FontWeight.Bold, accountViewModel.userProfile())
DisplayUserSetAsSubject(room, FontWeight.Normal)
DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal)
}
}
@@ -733,7 +734,7 @@ fun LongRoomHeader(
state = rememberLazyListState()
) {
itemsIndexed(list, key = { _, item -> item }) { _, item ->
LoadUser(baseUserHex = item) {
LoadUser(baseUserHex = item, accountViewModel) {
if (it != null) {
UserCompose(
baseUser = it,
@@ -22,7 +22,7 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
fun CommunityScreen(aTagHex: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
if (aTagHex == null) return
LoadAddressableNote(aTagHex = aTagHex) {
LoadAddressableNote(aTagHex = aTagHex, accountViewModel) {
it?.let {
PrepareViewModelsCommunityScreen(
note = it,
@@ -171,14 +171,20 @@ fun GeoHashActionOptions(
if (isFollowingTag) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.unfollowGeohash(tag)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
@@ -189,14 +195,20 @@ fun GeoHashActionOptions(
} else {
FollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.followGeohash(tag)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
@@ -147,14 +147,20 @@ fun HashtagActionOptions(
if (isFollowingTag) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.unfollowHashtag(tag)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
@@ -165,14 +171,20 @@ fun HashtagActionOptions(
} else {
FollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.followHashtag(tag)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
@@ -9,15 +9,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.*
import androidx.compose.material.DrawerValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Scaffold
import androidx.compose.material.rememberDrawerState
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
@@ -28,6 +19,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -37,12 +29,6 @@ import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
import com.vitorpamplona.amethyst.ui.buttons.NewImageButton
import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton
import com.vitorpamplona.amethyst.ui.navigation.*
import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
import com.vitorpamplona.amethyst.ui.navigation.AppTopBar
import com.vitorpamplona.amethyst.ui.navigation.DrawerContent
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -251,11 +237,14 @@ fun FloatingButtons(
navScrollToTop: (Route, Boolean) -> Unit
) {
val accountState by accountStateViewModel.accountContent.collectAsState()
val context = LocalContext.current
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
if (accountViewModel.loggedInWithExternalSigner()) {
WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop)
}
}
is AccountState.LoggedOff -> {
// Does nothing.
@@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -97,9 +98,9 @@ import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.GitHubIdentity
import com.vitorpamplona.quartz.events.IdentityClaim
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.MastodonIdentity
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
@@ -118,7 +119,7 @@ import java.math.BigDecimal
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
if (userId == null) return
var userBase by remember { mutableStateOf<User?>(LocalCache.getUserIfExists(userId)) }
var userBase by remember { mutableStateOf(LocalCache.getUserIfExists(userId)) }
if (userBase == null) {
LaunchedEffect(userId) {
@@ -280,7 +281,6 @@ fun ProfileScreen(
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderSurface(
baseUser: User,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
@@ -383,7 +383,7 @@ private fun RenderScreen(
) {
val pagerState = rememberPagerState { 9 }
Column() {
Column {
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background,
@@ -449,7 +449,7 @@ private fun CreateAndRenderTabs(
) {
val coroutineScope = rememberCoroutineScope()
val tabs = listOf<@Composable() (() -> Unit)?>(
val tabs = listOf<@Composable (() -> Unit)?>(
{ Text(text = stringResource(R.string.notes)) },
{ Text(text = stringResource(R.string.replies)) },
{ FollowTabHeader(baseUser) },
@@ -486,7 +486,7 @@ private fun RelaysTabHeader(baseUser: User) {
@Composable
private fun ReportsTabHeader(baseUser: User) {
val userState by baseUser.live().reports.observeAsState()
var userReports by remember { mutableStateOf(0) }
var userReports by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
@@ -503,11 +503,11 @@ private fun ReportsTabHeader(baseUser: User) {
@Composable
private fun FollowedTagsTabHeader(baseUser: User) {
var usertags by remember { mutableStateOf(0) }
var usertags by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = baseUser) {
launch(Dispatchers.IO) {
val contactList = baseUser?.latestContactList
val contactList = baseUser.latestContactList
val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0)
@@ -524,7 +524,7 @@ private fun FollowedTagsTabHeader(baseUser: User) {
private fun BookmarkTabHeader(baseUser: User) {
val userState by baseUser.live().bookmarks.observeAsState()
var userBookmarks by remember { mutableStateOf(0) }
var userBookmarks by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
@@ -621,7 +621,9 @@ private fun ProfileHeader(
modifier = Modifier
.size(30.dp)
.align(Alignment.Center),
onClick = { popupExpanded = true },
onClick = {
popupExpanded = true
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
@@ -744,14 +746,20 @@ private fun DisplayFollowUnfollowButton(
if (isLoggedInFollowingUser) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.unfollow(baseUser)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
@@ -763,14 +771,20 @@ private fun DisplayFollowUnfollowButton(
if (isUserFollowingLoggedIn) {
FollowButton(R.string.follow_back) {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.follow(baseUser)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
@@ -781,14 +795,20 @@ private fun DisplayFollowUnfollowButton(
} else {
FollowButton(R.string.follow) {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.follow(baseUser)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
@@ -918,7 +938,7 @@ private fun DrawAdditionalInfo(
}
}
DisplayBadges(baseUser, nav)
DisplayBadges(baseUser, accountViewModel, nav)
DisplayNip05ProfileStatus(user, accountViewModel)
@@ -980,7 +1000,7 @@ private fun DrawAdditionalInfo(
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = remember { ImmutableListOfLists<String>(emptyList()) },
tags = EmptyTagList,
backgroundColor = background,
accountViewModel = accountViewModel,
nav = nav
@@ -1092,7 +1112,7 @@ private fun DisplayAppRecommendations(
) { state ->
when (state) {
is FeedState.Loaded -> {
Column() {
Column {
Text(stringResource(id = R.string.recommended_apps))
FlowRow(
@@ -1115,7 +1135,7 @@ private fun DisplayAppRecommendations(
private fun WatchApp(baseApp: Note, nav: (String) -> Unit) {
val appState by baseApp.live().metadata.observeAsState()
var appLogo by remember { mutableStateOf<String?>(null) }
var appLogo by remember(baseApp) { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = appState) {
launch(Dispatchers.Default) {
@@ -1152,6 +1172,7 @@ private fun WatchApp(baseApp: Note, nav: (String) -> Unit) {
@Composable
private fun DisplayBadges(
baseUser: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadAddressableNote(
@@ -1160,20 +1181,29 @@ private fun DisplayBadges(
baseUser.pubkeyHex,
BadgeProfilesEvent.standardDTAg,
null
)
) {
if (it != null) {
val badgeList by it.live().metadata.map {
(it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList()
}.distinctUntilChanged().observeAsState()
badgeList?.let { list ->
RenderBadgeList(list, nav)
}
),
accountViewModel
) { note ->
if (note != null) {
WatchAndRenderBadgeList(note, nav)
}
}
}
@Composable
private fun WatchAndRenderBadgeList(
note: AddressableNote,
nav: (String) -> Unit
) {
val badgeList by note.live().metadata.map {
(it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList()
}.distinctUntilChanged().observeAsState()
badgeList?.let { list ->
RenderBadgeList(list, nav)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun RenderBadgeList(
@@ -1193,7 +1223,7 @@ private fun RenderBadgeList(
@Composable
private fun LoadAndRenderBadge(badgeAwardEventHex: String, nav: (String) -> Unit) {
var baseNote by remember {
mutableStateOf<Note?>(LocalCache.getNoteIfExists(badgeAwardEventHex))
mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEventHex))
}
LaunchedEffect(key1 = badgeAwardEventHex) {
@@ -1438,7 +1468,7 @@ fun TabFollows(baseUser: User, feedViewModel: UserFeedViewModel, accountViewMode
WatchFollowChanges(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column() {
Column {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
}
}
@@ -1449,7 +1479,7 @@ fun TabFollowers(baseUser: User, feedViewModel: UserFeedViewModel, accountViewMo
WatchFollowerChanges(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column() {
Column {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
}
}
@@ -1484,7 +1514,7 @@ fun TabReceivedZaps(baseUser: User, zapFeedViewModel: NostrUserProfileZapsFeedVi
WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel)
Column(Modifier.fillMaxHeight()) {
Column() {
Column {
LnZapFeedView(zapFeedViewModel, accountViewModel, nav)
}
}
@@ -1507,7 +1537,7 @@ fun TabReports(baseUser: User, feedViewModel: NostrUserProfileReportFeedViewMode
WatchReportsAndUpdateFeed(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column() {
Column {
RefresheableFeedView(
feedViewModel,
null,
@@ -1679,7 +1709,6 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
onDismissRequest = onDismiss
) {
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); onDismiss() }) {
Text(stringResource(R.string.copy_user_id))
@@ -1688,17 +1717,21 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
if (accountViewModel.userProfile() != user) {
Divider()
if (accountViewModel.account.isHidden(user)) {
DropdownMenuItem(onClick = {
accountViewModel.show(user)
onDismiss()
}) {
DropdownMenuItem(
onClick = {
accountViewModel.show(user)
onDismiss()
}
) {
Text(stringResource(R.string.unblock_user))
}
} else {
DropdownMenuItem(onClick = {
accountViewModel.hide(user)
onDismiss()
}) {
DropdownMenuItem(
onClick = {
accountViewModel.hide(user)
onDismiss()
}
) {
Text(stringResource(id = R.string.block_hide_user))
}
}
@@ -56,7 +56,7 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss:
Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal))
)
val reasonOptions = remember { reportTypes.map { it.second }.toImmutableList() }
val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableStateOf(-1) }
@@ -111,15 +111,15 @@ fun SettingsScreen(
) {
val scope = rememberCoroutineScope()
val selectedItens = persistentListOf(
stringResource(ConnectivityType.ALWAYS.reourceId),
stringResource(ConnectivityType.WIFI_ONLY.reourceId),
stringResource(ConnectivityType.NEVER.reourceId)
TitleExplainer(stringResource(ConnectivityType.ALWAYS.reourceId)),
TitleExplainer(stringResource(ConnectivityType.WIFI_ONLY.reourceId)),
TitleExplainer(stringResource(ConnectivityType.NEVER.reourceId))
)
val themeItens = persistentListOf(
stringResource(R.string.system),
stringResource(R.string.light),
stringResource(R.string.dark)
TitleExplainer(stringResource(R.string.system)),
TitleExplainer(stringResource(R.string.light)),
TitleExplainer(stringResource(R.string.dark))
)
val settings = accountViewModel.account.settings
@@ -132,7 +132,7 @@ fun SettingsScreen(
val context = LocalContext.current
val languageEntries = context.getLangPreferenceDropdownEntries()
val languageList = languageEntries.keys.toImmutableList()
val languageList = languageEntries.keys.map { TitleExplainer(it) }.toImmutableList()
val languageIndex = getLanguageIndex(languageEntries)
Column(
@@ -149,12 +149,12 @@ fun SettingsScreen(
) {
GlobalScope.launch(Dispatchers.Main) {
val job = scope.launch(Dispatchers.IO) {
val locale = languageEntries[languageList[it]]
val locale = languageEntries[languageList[it].title]
accountViewModel.account.settings.preferredLanguage = locale
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
job.join()
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(languageEntries[languageList[it]])
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(languageEntries[languageList[it].title])
AppCompatDelegate.setApplicationLocales(appLocale)
}
}
@@ -227,7 +227,7 @@ fun SettingsScreen(
fun SettingsRow(
name: Int,
description: Int,
selectedItens: ImmutableList<String>,
selectedItens: ImmutableList<TitleExplainer>,
selectedIndex: Int,
onSelect: (Int) -> Unit
) {
@@ -255,7 +255,7 @@ fun SettingsRow(
TextSpinner(
label = "",
placeholder = selectedItens[selectedIndex],
placeholder = selectedItens[selectedIndex].title,
options = selectedItens,
onSelect = onSelect,
modifier = Modifier
@@ -127,7 +127,6 @@ fun WatchAccountForVideoScreen(videoFeedView: NostrVideoFeedViewModel, accountVi
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SaveableFeedState(
videoFeedView: NostrVideoFeedViewModel,
@@ -1,7 +1,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedOff
import android.content.Context
import android.app.Activity
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@@ -21,7 +24,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
@@ -36,13 +38,20 @@ import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.PackageUtils
import com.vitorpamplona.amethyst.service.SignerType
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
@OptIn(ExperimentalComposeUiApi::class)
@@ -63,6 +72,58 @@ fun LoginPage(
val useProxy = remember { mutableStateOf(false) }
val proxyPort = remember { mutableStateOf("9050") }
var connectOrbotDialogOpen by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var loginWithExternalSigner by remember { mutableStateOf(false) }
val activity = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = {
loginWithExternalSigner = false
ExternalSignerUtils.isActivityRunning = false
ServiceManager.shouldPauseService = true
if (it.resultCode != Activity.RESULT_OK) {
scope.launch(Dispatchers.Main) {
Toast.makeText(
Amethyst.instance,
"Sign request rejected",
Toast.LENGTH_SHORT
).show()
}
return@rememberLauncherForActivityResult
} else {
val event = it.data?.getStringExtra("signature") ?: ""
key.value = TextFieldValue(event)
if (!acceptedTerms.value) {
termsAcceptanceIsRequired =
context.getString(R.string.acceptance_of_terms_is_required)
}
if (key.value.text.isBlank()) {
errorMessage = context.getString(R.string.key_is_required)
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.startUI(key.value.text, useProxy.value, proxyPort.value.toInt(), true)
} catch (e: Exception) {
Log.e("Login", "Could not sign in", e)
errorMessage = context.getString(R.string.invalid_key)
}
}
}
}
)
LaunchedEffect(loginWithExternalSigner) {
if (loginWithExternalSigner) {
ExternalSignerUtils.openSigner(
"",
SignerType.GET_PUBLIC_KEY,
activity,
"",
""
)
}
}
Column(
modifier = Modifier
@@ -229,7 +290,7 @@ fun LoginPage(
}
}
if (isPackageInstalled(context, "org.torproject.android")) {
if (PackageUtils.isOrbotInstalled(context)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = useProxy.value,
@@ -259,10 +320,10 @@ fun LoginPage(
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
Button(
enabled = acceptedTerms.value,
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired =
context.getString(R.string.acceptance_of_terms_is_required)
termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required)
}
if (key.value.text.isBlank()) {
@@ -281,15 +342,57 @@ fun LoginPage(
shape = RoundedCornerShape(Size35dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
)
.height(50.dp)
) {
Text(text = stringResource(R.string.login))
}
}
if (PackageUtils.isAmberInstalled(context)) {
Box(modifier = Modifier.padding(40.dp, 40.dp, 40.dp, 0.dp)) {
Button(
enabled = acceptedTerms.value,
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required)
return@Button
}
val result = ExternalSignerUtils.getDataFromResolver(SignerType.GET_PUBLIC_KEY, arrayOf("login"))
if (result == null) {
loginWithExternalSigner = true
return@Button
} else {
key.value = TextFieldValue(result)
if (key.value.text.isBlank()) {
errorMessage = context.getString(R.string.key_is_required)
return@Button
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.startUI(
key.value.text,
useProxy.value,
proxyPort.value.toInt(),
true
)
} catch (e: Exception) {
Log.e("Login", "Could not sign in", e)
errorMessage = context.getString(R.string.invalid_key)
}
}
}
},
shape = RoundedCornerShape(Size35dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
Text(text = stringResource(R.string.login_with_external_signer))
}
}
}
}
// The last child is glued to the bottom.
@@ -315,7 +418,3 @@ fun LoginPage(
)
}
}
fun isPackageInstalled(context: Context, target: String): Boolean {
return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null
}
@@ -26,6 +26,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.resolveDefaults
import com.patrykandpatrick.vico.compose.style.ChartStyle
@@ -347,11 +348,14 @@ fun AmethystTheme(themeViewModel: ThemeViewModel, content: @Composable () -> Uni
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
if (darkTheme) {
window.statusBarColor = colors.background.toArgb()
} else {
window.statusBarColor = colors.primary.toArgb()
}
window.navigationBarColor = colors.background.toArgb()
insetsController.isAppearanceLightNavigationBars = !darkTheme
}
}
}
+502 -1
View File
@@ -1,2 +1,503 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"></resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="point_to_the_qr_code">QR কোডের দিকে নির্দেশ করুন</string>
<string name="show_qr">QR দেখান</string>
<string name="profile_image">প্রোফাইল ছবি</string>
<string name="scan_qr">QR স্ক্যান করুন</string>
<string name="show_anyway">যাহোক, দেখান</string>
<string name="post_was_flagged_as_inappropriate_by">পোস্টটি কারো দ্বারা অভিযুক্ত হয়েছে</string>
<string name="post_not_found">ইভেন্টটি লোড হচ্ছে অথবা আপনার রিলে তালিকায় এটি পাওয়া যাচ্ছে না</string>
<string name="channel_image">চ্যানেল ইমেজ</string>
<string name="referenced_event_not_found">উল্লেখিত ইভেন্টটি পাওয়া যায় নি</string>
<string name="could_not_decrypt_the_message">মেসেজটি ডিক্রিপ্ট করা যায় নি</string>
<string name="group_picture">দলগত ছবি</string>
<string name="explicit_content">খোলামেলা আধেয়</string>
<string name="spam">স্প্যাম</string>
<string name="impersonation">ছদ্মবেশ</string>
<string name="illegal_behavior">বেআইনী আচরণ</string>
<string name="unknown">অচেনা</string>
<string name="relay_icon">রিলে আইকন</string>
<string name="unknown_author">অজ্ঞাত-পরিচয় লেখক</string>
<string name="copy_text">টেক্সটটি কপি করুন</string>
<string name="copy_user_pubkey">লেখক আইডি কপি করুন</string>
<string name="copy_note_id">নোটের আইডি কপি করুন</string>
<string name="broadcast">সম্প্রচার</string>
<string name="request_deletion">নোটটি মুছে ফেলার অনুরোধ করুন</string>
<string name="block_report">ব্লক/অভিযুক্ত করুন</string>
<string name="block_hide_user"><![CDATA[ব্লক করুন এবং ব্যবহারকারীকে আড়ালে রাখুন]]></string>
<string name="report_spam_scam">স্প্যাম/প্রতারণার অভিযোগ করুন</string>
<string name="report_impersonation">ছদ্মবেশ ধারণের অভিযোগ করুন</string>
<string name="report_explicit_content">খোলামেলা আধেয় হিসেবে অভিযোগ করুন</string>
<string name="report_illegal_behaviour">বেআইনী আচরণের অভিযোগ করুন</string>
<string name="login_with_a_private_key_to_be_able_to_reply">প্রতিউত্তর দিতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">পোস্টগুলি বুস্ট করতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন</string>
<string name="login_with_a_private_key_to_like_posts">পোস্টগুলি লাইক করতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন</string>
<string name="no_zap_amount_setup_long_press_to_change">জ্যাপের কোনো পরিমাণ নির্দিষ্ট করা নেই। এটি পরিবর্তন করতে চাইলে দীর্ঘক্ষণ চাপুন</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">কাউকে জ্যাপ পাঠাতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন</string>
<string name="login_with_a_private_key_to_be_able_to_follow">কাউকে অনুসরণ করতে ব্যক্তিগত চাবি দিয়ে লগইন করুন</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">কাউকে আনফলো করতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন</string>
<string name="zaps">জ্যাপস</string>
<string name="view_count">পরিমাণ দেখুন</string>
<string name="boost">বুস্ট</string>
<string name="boosted">বুস্ট করা হয়েছে</string>
<string name="quote">উদ্ধৃতি দিন</string>
<string name="new_amount_in_sats">স্যাটে নতুন পরিমাণ যুক্ত হয়েছে</string>
<string name="add">যুক্ত করুন</string>
<string name="replying_to">"এই ব্যক্তির প্রতিউত্তর দিচ্ছেন "</string>
<string name="and">" এবং "</string>
<string name="in_channel">"চ্যানেলের ভেতর "</string>
<string name="profile_banner">প্রোফাইলের ব্যানার</string>
<string name="payment_successful">সফলভাবে মূল্য পরিশোধিত হয়েছে</string>
<string name="error_parsing_error_message">Error parsing error message</string>
<string name="following">" অনুসরণ"</string>
<string name="followers">" অনুসারী"</string>
<string name="profile">প্রোফাইল</string>
<string name="security_filters">নিরাপত্তা-ফিল্টার</string>
<string name="log_out">লগ আউট</string>
<string name="show_more">আরও দেখান</string>
<string name="lightning_invoice">বিজলি-ইনভয়েস</string>
<string name="pay">পরিশোধ করুন</string>
<string name="lightning_tips">বিজলি-বকশিশ</string>
<string name="note_to_receiver">প্রাপকের উদ্দেশে চিরকুট</string>
<string name="thank_you_so_much">আপনাকে অনেক ধন্যবাদ!</string>
<string name="amount_in_sats">স্যাটে মোট পরিমাণ</string>
<string name="send_sats">স্যাট পাঠান</string>
<string name="error_parsing_preview_for">"এই দুইয়ের পূর্বরূপ দেখানো যাচ্ছে না %1$s : %2$s"</string>
<string name="preview_card_image_for">"%1$s এর জন্য কার্ড ইমেজের পূর্বরূপ দেখুন"</string>
<string name="new_channel">নতুন চ্যানেল</string>
<string name="channel_name">চ্যানেলের নাম</string>
<string name="my_awesome_group">আমার চম‍‌‌‍ৎকার গ্রুপ</string>
<string name="picture_url">ছবির Url</string>
<string name="description">বিবরণ</string>
<string name="about_us">"আমাদের সম্পর্কে.. "</string>
<string name="what_s_on_your_mind">নিজের কোনো ভাবনা প্রকাশ করতে চান?</string>
<string name="post">পেশ করুন</string>
<string name="save">সংরক্ষণ করুন</string>
<string name="create">তৈরি করুন</string>
<string name="cancel">বাতিল করুন</string>
<string name="failed_to_upload_the_image">ছবিটি সফলভাবে আপলোড করা যায় নি</string>
<string name="relay_address">রিলে-ঠিকানা</string>
<string name="posts">পোস্টগুলি</string>
<string name="bytes">বাইটস</string>
<string name="errors">ত্রুটিসমূহ</string>
<string name="home_feed">নীড়পাতা</string>
<string name="private_message_feed">ব্যক্তিগত বার্তাসমূহ</string>
<string name="public_chat_feed">সার্বজনীন কথোপকথন</string>
<string name="global_feed">বৈশ্বিক পাতা</string>
<string name="search_feed">অনুসন্ধান পাতা</string>
<string name="add_a_relay">একটি রিলে যুক্ত করুন</string>
<string name="display_name">প্রদর্শিত নাম</string>
<string name="my_display_name">আমার প্রদর্শিত নাম</string>
<string name="username">ইউজারনেম</string>
<string name="my_username">আমার ইউজারনেম</string>
<string name="about_me">আমার সম্পর্কে</string>
<string name="avatar_url">অবতারের URL</string>
<string name="banner_url">ব্যানারের URL</string>
<string name="website_url">ওয়েবসাইটের URL</string>
<string name="ln_address">বিজলি-ঠিকানা</string>
<string name="ln_url_outdated">বিজলি URL (অপ্রচলিত)</string>
<string name="image_saved_to_the_gallery">ছবিটি গ্যালারিতে সংরক্ষণ করা হয়েছে</string>
<string name="failed_to_save_the_image">ছবিটি সফলভাবে সংরক্ষণ করা যায় নি</string>
<string name="upload_image">ছবিটি আপলোড করুন</string>
<string name="uploading">আপলোড করা হচ্ছে…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">স্যাট গ্রহণের জন্য ব্যবহারকারীর কোনো বিজলি-ঠিকানা দেওয়া নেই</string>
<string name="reply_here">"এখানে প্রতিউত্তর দিন.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">নস্টারে শেয়ারের জন্য নোট আইডিটি ক্লিপবোর্ডে কপি করা হয়</string>
<string name="copy_channel_id_note_to_the_clipboard">চ্যানেল আইডি (নোট) ক্লিপবোর্ডে কপি করুন</string>
<string name="edits_the_channel_metadata">চ্যানেলের মেটাডেটা সম্পাদনা করে</string>
<string name="join">যুক্ত হোন</string>
<string name="known">জ্ঞাত</string>
<string name="new_requests">নয়া অনুরোধসমূহ</string>
<string name="blocked_users">ব্লক করে রাখা ব্যবহারকারীরা</string>
<string name="new_threads">নতুন থ্রেড</string>
<string name="conversations">কথোপকথন</string>
<string name="notes">নোটস</string>
<string name="replies">প্রতিউত্তরসমূহ</string>
<string name="follows">"অনুসারী"</string>
<string name="reports">"অভিযোগসমূহ"</string>
<string name="more_options">আরও বিকল্প</string>
<string name="relays">" রিলেগুলি"</string>
<string name="website">ওয়েবসাইট</string>
<string name="lightning_address">বিজলি-ঠিকানা</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">ব্যাকআপের জন্য আপনার Nsec আইডি (তথা পাসওয়ার্ড) ক্লিপবোর্ডে কপি করে</string>
<string name="copy_private_key_to_the_clipboard">আপনার ব্যক্তিগত চাবিটি ক্লিপবোর্ডে কপি করুন</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">অন্যদের সাথে শেয়ারের জন্য আপনার গণ চাবি ক্লিপবোর্ডে কপি করে</string>
<string name="copy_public_key_npub_to_the_clipboard">গণ চাবি (Npub) ক্লিপবোর্ডে কপি করুন</string>
<string name="send_a_direct_message">একটি সরাসরি-বার্তা পাঠান</string>
<string name="edits_the_user_s_metadata">ব্যবহারকারীর মেটাডেটা সম্পাদনা করে</string>
<string name="follow">অনুসরণ করুন</string>
<string name="follow_back">প্রতি-অনুসরণ করুন</string>
<string name="unblock">আনব্লক করুন</string>
<string name="copy_user_id">ব্যবহারকারীর আইডি কপি করুন</string>
<string name="unblock_user">ব্যবহারকারীকে আনব্লক করুন</string>
<string name="npub_hex_username">"গণচাবি, ইউজারনেম, টেক্সট"</string>
<string name="clear">মুছে ফেলুন</string>
<string name="app_logo">অ্যাপের লোগো</string>
<string name="nsec_npub_hex_private_key">Nsec.. অথবা Npub..</string>
<string name="show_password">পাসওয়ার্ড দেখান</string>
<string name="hide_password">পাসওয়ার্ড লুকান</string>
<string name="invalid_key">অকেজো চাবি</string>
<string name="i_accept_the">"আমি এটা মেনে নিচ্ছি "</string>
<string name="terms_of_use">ব্যবহারের শর্তাবলী</string>
<string name="acceptance_of_terms_is_required">প্রদত্ত শর্তাবলী কবুল করা আবশ্যক</string>
<string name="key_is_required">চাবি প্রয়োজন</string>
<string name="login">লগ ইন</string>
<string name="generate_a_new_key">একটি নতুন চাবি তৈরি করুন</string>
<string name="loading_feed">পাতাটি লোড করা হচ্ছে</string>
<string name="error_loading_replies">"প্রতিউত্তরগুলি লোড করা যাচ্ছে না: "</string>
<string name="try_again">আবার চেষ্টা করুন</string>
<string name="feed_is_empty">পাতাটি খালি</string>
<string name="refresh">রিফ্রেশ করুন</string>
<string name="created">তৈরি করা হয়েছে</string>
<string name="with_description_of">এর বিবরণ সহ</string>
<string name="and_picture">এবং ছবি</string>
<string name="changed_chat_name_to">চ্যাটের নাম পরিবর্তন করুন</string>
<string name="description_to">বিবরণ</string>
<string name="and_picture_to">এবং ছবি</string>
<string name="leave">ছেড়ে দিন</string>
<string name="unfollow">আনফলো করুন</string>
<string name="channel_created">চ্যানেলটি তৈরি করা হয়েছে</string>
<string name="channel_information_changed_to">"চ্যানেলের তথ্যের পরিবর্তিত চেহারা"</string>
<string name="public_chat">সার্বজনীন আলাপ</string>
<string name="posts_received">পেশকৃত আধেয় গৃহীত হয়েছে</string>
<string name="remove">সরিয়ে ফেলুন</string>
<string name="translations_auto">স্বয়ংক্রিয়</string>
<string name="translations_translated_from">এই মূলপাঠ থেকে অনূদিত হয়েছে</string>
<string name="translations_to">থেকে</string>
<string name="translations_show_in_lang_first">প্রথমে %1$s ভাষায় দেখান</string>
<string name="translations_always_translate_to_lang">সবসময় %1$s ভাষায় অনুবাদ করুন</string>
<string name="translations_never_translate_from_lang">কখনোই %1$s ভাষা থেকে অনুবাদ করবেন না</string>
<string name="nip_05">নস্টার ঠিকানা</string>
<string name="never">কখনো না</string>
<string name="now">এখন</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">নগ্নতা</string>
<string name="profanity_hateful_speech">অশ্লীলতা / বিদ্বেষপূর্ণ বক্তব্য</string>
<string name="report_hateful_speech">বিদ্বেষপূর্ণ বক্তব্য হিসেবে অভিযোগ জানান</string>
<string name="report_nudity_porn">নগ্নতা / পর্ন হিসেবে অভিযোগ জানান</string>
<string name="others">অন্যান্য</string>
<string name="mark_all_known_as_read">পরিচিত সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন</string>
<string name="mark_all_new_as_read">নতুন সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন</string>
<string name="mark_all_as_read">সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন</string>
<string name="backup_keys">ব্যাকআপ চাবিগুলি</string>
<string name="account_backup_tips_md" tools:ignore="Typos"> ## চাবির ব্যাকআপ এবং নিরাপত্তা বিষয়ক টিপস
\n\nআপনার অ্যাকাউন্ট একটি ব্যক্তিগত চাবি দ্বারা সুরক্ষিত। চাবি হলো এলোমেলো অক্ষর ও সংখ্যার একটি দীর্ঘ তন্তু যা **nsec** দ্বারা শুরু হয়। আপনার ব্যক্তিগত চাবিটি পেলে যে কেউ আপনার পরিচয় ব্যবহার করে যেকোনো আধেয় প্রকাশ করতে পারবে।
\n\n- আপনি বিশ্বাস করেন না এমন কোনো ওয়েবসাইট কিংবা সফটওয়্যারে আপনার ব্যক্তিগত চাবিটি রাখবেন **না**।
\n- অ্যামেথিস্টের ডেভেলপাররা **কখনোই** আপনার কাছে আপনার ব্যক্তিগত চাবিটি জানতে চাইবে না।
\n- আপনার ব্যক্তিগত চাবির একটি নিরাপদ ব্যাকআপ **অবশ্যই** সংরক্ষণ করবেন কারণ এটি আপনার অ্যাকাউন্ট পুনরুদ্ধারে কাজে আসবে। আমরা একটি পাসওয়ার্ড ম্যানেজার ব্যবহারের পরামর্শ দিই।
</string>
<string name="secret_key_copied_to_clipboard">ব্যক্তিগত চাবি (nsec) ক্লিপবোর্ডে কপি করা হয়েছে</string>
<string name="copy_my_secret_key">আমার ব্যক্তিগত চাবিটি কপি করুন</string>
<string name="biometric_authentication_failed">প্রমাণীকরণ ব্যর্থ হয়েছে।</string>
<string name="biometric_error">ত্রুটি</string>
<string name="badge_created_by">"%1$s দ্বারা নির্মিত"</string>
<string name="badge_award_image_for">"%1$s -এর জন্য ব্যাজ পুরষ্কারের ছবি"</string>
<string name="new_badge_award_notif">আপনি একটি নতুন ব্যাজ পুরষ্কার পেয়েছেন</string>
<string name="award_granted_to">ব্যাজ পুরস্কারটি পাচ্ছে</string>
<string name="copied_note_text_to_clipboard">নোটের রচনাটি ক্লিপবোর্ডে কপি করা হয়েছে</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">রচয়িতার @npub ক্লিপবোর্ডে কপি করুন</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">নোট আইডিটি (@note1) ক্লিপবোর্ডে কপি করা হয়েছে</string>
<string name="select_text_dialog_top">রচনাটি নির্বাচন করুন</string>
<string name="private_conversation_notification">"&lt;Unable to decrypt private message&gt;\n\nআপনাকে %1$s এবং %2$s এর মধ্যে একটি ব্যক্তিগত/এনক্রিপ্টেড কথোপকথনে উদ্ধৃত করা হয়েছে৷"</string>
<string name="account_switch_add_account_dialog_title">নতুন অ্যাকাউন্ট যুক্ত করুন</string>
<string name="drawer_accounts">অ্যাকাউন্টগুলি</string>
<string name="account_switch_select_account">অ্যাকাউন্ট নির্বাচন করুন</string>
<string name="account_switch_add_account_btn">নতুন অ্যাকাউন্ট যুক্ত করুন</string>
<string name="account_switch_active_account">অ্যাকাউন্ট সক্রিয় করুন</string>
<string name="account_switch_has_private_key">ব্যক্তিগত চাবি আছে</string>
<string name="account_switch_pubkey_only">শুধুমাত্র পাঠযোগ্য, ব্যক্তিগত চাবি নেই</string>
<string name="back">পিছে যান</string>
<string name="quick_action_select">নির্বাচন করুন</string>
<string name="quick_action_share_browser_link">ব্রাউজারের লিংক শেয়ার করুন</string>
<string name="quick_action_share">শেয়ার</string>
<string name="quick_action_copy_user_id">লেখকের আইডি</string>
<string name="quick_action_copy_note_id">নোটের আইডি</string>
<string name="quick_action_copy_text">টেক্সটটি কপি করুন</string>
<string name="quick_action_delete">মুছে ফেলুন</string>
<string name="quick_action_unfollow">আনফলো করুন</string>
<string name="quick_action_follow">অনুসরণ করুন</string>
<string name="quick_action_request_deletion_alert_title">নোটটি মুছে ফেলার অনুরোধ করুন</string>
<string name="quick_action_request_deletion_alert_body">অ্যামেথিস্ট অনুরোধ করবে যেন আপনার নোটটি আপনার বর্তমানে সংযুক্ত রিলেগুলো থেকে মুছে ফেলা হয়। তবে কোনো নিশ্চয়তা নেই যে নোটটি স্থায়ীভাবে সেই রিলেগুলি থেকে বা অন্যান্য রিলে থেকে মুছে ফেলা হবে, যেখানে এটি সংরক্ষণ করা হতে পারে৷</string>
<string name="quick_action_block_dialog_btn">ব্লক করুন</string>
<string name="quick_action_delete_dialog_btn">মুছে ফেলুন</string>
<string name="quick_action_block">ব্লক করুন</string>
<string name="quick_action_report">অভিযোগ করুন</string>
<string name="quick_action_delete_button">মুছে ফেলুন</string>
<string name="quick_action_dont_show_again_button">আর দেখাবেন না</string>
<string name="report_dialog_spam">স্প্যাম অথবা প্রতারণা</string>
<string name="report_dialog_profanity">অশ্লীলতা বা বিদ্বেষপূর্ণ আচরণ</string>
<string name="report_dialog_impersonation">ক্রূর উদ্দেশ্য সাধনে ছদ্মবেশ</string>
<string name="report_dialog_nudity">নগ্নতা বা গ্রাফিক আধেয়</string>
<string name="report_dialog_illegal">বেআইনী আচরণ</string>
<string name="report_dialog_blocking_a_user">কোনো ব্যবহারকারীকে ব্লক করে রাখলে আপনার অ্যাপে তাদের কোনো আধেয় দেখা যাবে না। তবে আপনার নোটগুলি সর্বজনীনভাবে উন্মুক্ত, আপনার দ্বারা ব্লক করা ব্যক্তিরাও তা দেখতে পাবে। ব্লক করে রাখা ব্যবহারকারীদেরকে নিরাপত্তা-ফিল্টার স্ক্রিনে তালিকাভুক্ত করা হয়েছে।</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[ব্যবহারকারীকে ব্লক করুন এবং আড়ালে রাখুন]]></string>
<string name="report_dialog_report_btn">অবমাননার অভিযোগ করুন</string>
<string name="report_dialog_reminder_public">পেশকৃত সমস্ত অভিযোগ সর্বজনীনভাবে দৃশ্যমান হবে৷</string>
<string name="report_dialog_additional_reason_placeholder">আপনি চাইলে আপনার অভিযোগের সাথে অতিরিক্ত অনুসঙ্গ যোগ করতে পারেন…</string>
<string name="report_dialog_additional_reason_label">সংযোজিত অনুসঙ্গ</string>
<string name="report_dialog_select_reason_label">কারণ</string>
<string name="report_dialog_select_reason_placeholder">একটি কারণ নির্বাচন করুন…</string>
<string name="report_dialog_post_report_btn">অভিযোগ পেশ করুন</string>
<string name="report_dialog_title">ব্লক করুন এবং অভিযোগ পেশ করুন</string>
<string name="block_only">ব্লক করুন</string>
<string name="bookmarks">বুকমার্ক করুন</string>
<string name="private_bookmarks">ব্যক্তিগত বুকমার্ক</string>
<string name="public_bookmarks">সর্বজনীন বুকমার্ক</string>
<string name="add_to_private_bookmarks">ব্যক্তিগত বুকমার্কে যুক্ত করুন</string>
<string name="add_to_public_bookmarks">সর্বজনীন বুকমার্কে যুক্ত করুন</string>
<string name="remove_from_private_bookmarks">ব্যক্তিগত বুকমার্ক থেকে সরিয়ে দিন</string>
<string name="remove_from_public_bookmarks">সর্বজনীন বুকমার্ক থেকে সরিয়ে দিন</string>
<string name="wallet_connect_service">ওয়ালেট সংযোগের পরিষেবা</string>
<string name="wallet_connect_service_explainer">অ্যাপ থেকে বের না হয়েই জ্যাপ প্রদান করতে একটি নস্টার-সিক্রেট অনুমোদন করা হয়ে থাকে। এই নস্টার-সিক্রেটটি নিরাপদে রাখুন এবং সম্ভব হলে একটি ব্যক্তিগত রিলে ব্যবহার করুন</string>
<string name="wallet_connect_service_pubkey">ওয়ালেট সংযোগের গণ চাবি</string>
<string name="wallet_connect_service_relay">ওয়ালেট সংযোগের রিলে</string>
<string name="wallet_connect_service_secret">ওয়ালেট সংযোগের সিক্রেট</string>
<string name="wallet_connect_service_show_secret">গোপন চাবি দেখান</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex ব্যক্তিগত চাবি</string>
<string name="pledge_amount_in_sats">প্রতিশ্রুত পরিমাণ স্যাট দ্বারা জানান</string>
<string name="post_poll">একটি পোল পেশ করুন</string>
<string name="poll_heading_required">প্রয়োজনীয় ক্ষেত্রসমূহ:</string>
<string name="poll_zap_recipients">জ্যাপ প্রাপকেরা</string>
<string name="poll_primary_description">পোলের প্রাথমিক বিবরণ…</string>
<string name="poll_option_index">বিকল্প %s</string>
<string name="poll_option_description">পোলের বিবিধ বিকল্পের বিবরণ</string>
<string name="poll_heading_optional">ঐচ্ছিক ক্ষেত্র:</string>
<string name="poll_zap_value_min">ন্যূনতম জ্যাপ</string>
<string name="poll_zap_value_max">সর্বোচ্চ জ্যাপ</string>
<string name="poll_consensus_threshold">ঐক্যমত</string>
<string name="poll_consensus_threshold_percent">(০–১০০)%</string>
<string name="poll_closing_time">এই সময় পর বন্ধ করুন</string>
<string name="poll_closing_time_days">দিনগুলি</string>
<string name="poll_is_closed">পোলটি আর নতুন ভোট গ্রহণ করবে না</string>
<string name="poll_zap_amount">পরিমাণটি জ্যাপ করুন</string>
<string name="one_vote_per_user_on_atomic_votes">এই জাতীয় পোলে একজন ব্যবহারকারী শুধুমাত্র একটি করেই ভোট দিতে পারবেন</string>
<string name="looking_for_event">"ইভেন্ট খোঁজা হচ্ছে %1$s"</string>
<string name="custom_zaps_add_a_message">একটি সর্বজনীন বার্তা পাঠান</string>
<string name="custom_zaps_add_a_message_private">একটি ব্যক্তিগত বার্তা পাঠান</string>
<string name="custom_zaps_add_a_message_nonzap">একটি ইনভয়েস বার্তা পাঠান</string>
<string name="custom_zaps_add_a_message_example">আপনার সকল কাজের জন্য আপনাকে অসংখ্য ধন্যবাদ!</string>
<string name="lightning_create_and_add_invoice">তৈরি করুন এবং যোগ করুন</string>
<string name="poll_author_no_vote">পোল-রচয়িতারা নিজেদের পোলে ভোট দিতে পারবেন না।</string>
<string name="hash_verification_passed">ঐ পোস্ট এবং এই আধেয়র বিষয়বস্তু অভিন্ন</string>
<string name="hash_verification_failed">এই আধেয়র বিষয়বস্তু পরিবর্তিত হয়েছে। লেখক হয়তো পরিবর্তনটি দেখেন নি বা অনুমোদন করেন নি</string>
<string name="content_description_add_image">ছবি যোগ করুন</string>
<string name="content_description_add_video">ভিডিও যোগ করুন</string>
<string name="content_description_add_document">নথি যোগ করুন</string>
<string name="add_content">বার্তায় যোগ করুন</string>
<string name="content_description">বিষয়বস্তুর বিবরণ</string>
<string name="content_description_example">সূর্যাস্তের সময় সাদা বালুকাময় সমুদ্র সৈকতে একটি নীল নৌকা</string>
<string name="zap_type">জ্যাপের ধরন</string>
<string name="zap_type_explainer">সকল বিকল্পগুলিতে জ্যাপের ধরন</string>
<string name="zap_type_public">সর্বজনীন</string>
<string name="zap_type_public_explainer">লেনদেন এবং বার্তা সকলে দেখতে পারেন</string>
<string name="zap_type_private">ব্যক্তিগত</string>
<string name="zap_type_private_explainer">প্রেরক এবং প্রাপক একে অপরকে দেখতে এবং বার্তা পড়তে পারেন</string>
<string name="zap_type_anonymous">অজ্ঞাত-পরিচয়</string>
<string name="zap_type_anonymous_explainer">প্রাপক এবং জনসাধারণ জানেন না কে প্রদত্ত অর্থ পাঠিয়েছেন</string>
<string name="zap_type_nonzap">নন-জ্যাপ</string>
<string name="zap_type_nonzap_explainer">নস্টারে কোনো সন্ধান নেই, কেবল বিজলিতে আছে</string>
<string name="file_server">ফাইল সার্ভার</string>
<string name="zap_forward_lnAddress">বিজলি-ঠিকানা বা @ব্যবহারকারী</string>
<string name="upload_server_imgur">imgur.com - বিশ্বস্ত</string>
<string name="upload_server_imgur_explainer">Imgur ফাইলটি পরিবর্তন করতে পারে</string>
<string name="upload_server_nostrimg">nostrimg.com - বিশ্বস্ত</string>
<string name="upload_server_nostrimg_explainer">NostrImg ফাইলটি পরিবর্তন করতে পারে</string>
<string name="upload_server_nostrbuild">nostr.build - বিশ্বস্ত</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build ফাইলটি পরিবর্তন করতে পারে</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - বিশ্বস্ত</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev ফাইলটি পরিবর্তন করতে পারে</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - বিশ্বস্ত</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me ফাইলটি পরিবর্তন করতে পারে</string>
<string name="upload_server_imgur_nip94">যাচাইযোগ্য Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Imgur ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে</string>
<string name="upload_server_nostrimg_nip94">যাচাইযোগ্য NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">NostrImg ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে</string>
<string name="upload_server_nostrbuild_nip94">যাচাইযোগ্য Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Nostr.build ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে</string>
<string name="upload_server_nostrfilesdev_nip94">যাচাইযোগ্য Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Nostrfiles.dev ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে</string>
<string name="upload_server_nostrcheckme_nip94">যাচাইযোগ্য Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Nostrcheck.me ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে</string>
<string name="upload_server_relays_nip95">আপনার রিলেগুলি (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">ফাইলগুলো আপনার রিলে দ্বারা গৃহীত হয়। নতুন NIP: তারা এটি সমর্থন করে কিনা দেখে নিন</string>
<string name="connect_via_tor_short">Tor/Orbot সেটআপ করুন</string>
<string name="connect_via_tor">আপনার Orbot সেটআপের মাধ্যমে সংযুক্ত হোন</string>
<string name="do_you_really_want_to_disable_tor_title">Orbot/Tor থেকে সংযোগ বিচ্ছিন্ন করতে চান?</string>
<string name="do_you_really_want_to_disable_tor_text">আপনার ডেটা অবিলম্বে নিয়মিত নেটওয়ার্কে স্থানান্তর করা হবে</string>
<string name="yes">হ্যাঁ</string>
<string name="no">না</string>
<string name="follow_list_selection">অনুসরণ তালিকা</string>
<string name="follow_list_kind3follows">অনুসৃত সকলেরা</string>
<string name="follow_list_global">বৈশ্বিক</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Tor এর মাধ্যমে Orbot এর সাথে সংযুক্ত হোন
\n\n১. [Orbot] নামান (https://play.google.com/store/apps/details?id=org.torproject.android)
\n২. Orbot চালু করুন
\n৩. Orbot এ ঢুকে Socks পোর্টটি খেয়াল করুন। এটি ৯০৫০ তে নির্দিষ্ট করা থাকে
\n৪. প্রয়োজনে Orbot এ পোর্টটি বদলে দিন
\n৫. স্ক্রিনের Socks পোর্টটি প্রস্তুত করুন
\n৬. Orbot কে প্রক্সি হিসেবে ব্যবহার করতে Activate বোতামটি চাপুন
</string>
<string name="orbot_socks_port">Orbot এর Socks পোর্ট</string>
<string name="invalid_port_number">পোর্ট নম্বরটি অকার্যকর</string>
<string name="use_orbot">Orbot ব্যবহার করুন</string>
<string name="disconnect_from_your_orbot_setup">Tor/Orbot থেকে সংযোগ বিচ্ছিন্ন করুন</string>
<string name="app_notification_dms_channel_name">ব্যক্তিগত বার্তাসমূহ</string>
<string name="app_notification_dms_channel_description">যখন একটি ব্যক্তিগত বার্তা আসে তখন আপনাকে অবহিত করে</string>
<string name="app_notification_zaps_channel_name">জ্যাপ গৃহীত হয়েছে</string>
<string name="app_notification_zaps_channel_description">কেউ জ্যাপ পাঠালে আপনাকে অবহিত করে</string>
<string name="app_notification_zaps_channel_message">%1$s স্যাট</string>
<string name="app_notification_zaps_channel_message_from">%1$s থেকে</string>
<string name="app_notification_zaps_channel_message_for">%1$s এর জন্য</string>
<string name="reply_notify">অবহিত করুন: </string>
<string name="channel_list_join_conversation">আলোচনায় যুক্ত হোন</string>
<string name="channel_list_user_or_group_id">ব্যবহারকারী কিংবা গ্রুপের আইডি</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent অথবা hex</string>
<string name="channel_list_create_channel">তৈরি করুন</string>
<string name="channel_list_join_channel">যুক্ত হোন</string>
<string name="today">আজ</string>
<string name="content_warning">আধেয় বিষয়ক সতর্কতা</string>
<string name="content_warning_explanation">এই পোস্টে সংবেদনশীল উপাদান রয়েছে যা কারো জন্য আপত্তিকর কিংবা সমস্যাজনক হতে পারে</string>
<string name="content_warning_hide_all_sensitive_content">সংবেদনশীল আধেয় সবসময় আড়ালে রাখুন</string>
<string name="content_warning_show_all_sensitive_content">সংবেদনশীল বিষয়বস্তু সবসময় খোলামেলা দেখান</string>
<string name="content_warning_see_warnings">সবসময় আধেয় বিষয়ক সতর্কতা দেখান</string>
<string name="recommended_apps">প্রস্তাবিত: </string>
<string name="filter_spam_from_strangers">অপরিচিতদের কাছ থেকে স্প্যাম ফিল্টার করুন</string>
<string name="warn_when_posts_have_reports_from_your_follows">কোনো পোস্টে আপনার অনুসরীদের থেকে অভিযোগ থাকলে অবহিত হোন</string>
<string name="new_reaction_symbol">প্রতিক্রিয়ার নতুন প্রতীক</string>
<string name="no_reaction_type_setup_long_press_to_change">প্রতিক্রিয়ার কোনো ধরন বেছে নেওয়া হয়নি। পরিবর্তন করতে দীর্ঘক্ষণ চাপুন</string>
<string name="zapraiser">জ্যাপরেইজার</string>
<string name="zapraiser_explainer">এই পোস্টের জন্য স্যাটের একটি লক্ষ্যমাত্রা যোগ করে। সহায়তাকারী ক্লায়েন্টরা অনুদানকে উৎসাহিত করার জন্য একে একটি অগ্রগতির মানদণ্ড হিসাবে দেখাতে পারে</string>
<string name="zapraiser_target_amount_in_sats">পরিমাণের লক্ষ্যমাত্রা স্যাট দ্বারা জানান</string>
<string name="sats_to_complete">জ্যাপরাইজার %1$s তে আছে। %2$s স্যাট লক্ষ্যমাত্রা</string>
<string name="read_from_relay">রিলে থেকে পড়ুন</string>
<string name="write_to_relay">রিলেতে লিখুন</string>
<string name="an_error_occurred_trying_to_get_relay_information">%1$s থেকে রিলের তথ্যের জন্য চেষ্টা করার সময় একটি ত্রুটি ঘটেছে৷</string>
<string name="owner">মালিক</string>
<string name="version">সংস্করণ</string>
<string name="software">সফটওয়্যার</string>
<string name="contact">যোগাযোগ</string>
<string name="supports">সমর্থিত NIP গুলো</string>
<string name="admission_fees">প্রবেশমূল্য</string>
<string name="payments_url">মূল্যপরিশোধের URL</string>
<string name="limitations">সীমাবদ্ধতা</string>
<string name="countries">দেশসমূহ</string>
<string name="languages">ভাষাসমূহ</string>
<string name="tags">ট্যাগগুলি</string>
<string name="posting_policy">আধেয় পেশ করার নীতি</string>
<string name="message_length">বার্তার ব্যাপ্তি</string>
<string name="subscriptions">সকল সদস্যতা</string>
<string name="filters">ফিল্টারগুলি</string>
<string name="subscription_id_length">সদস্যতা আইডির ব্যাপ্তি</string>
<string name="minimum_prefix">ন্যূনতম উপসর্গ</string>
<string name="maximum_event_tags">সর্বাধিক ইভেন্ট ট্যাগ</string>
<string name="content_length">আধেয়র ব্যাপ্তি</string>
<string name="minimum_pow">ন্যূনতম PoW</string>
<string name="auth">অনুমোদন</string>
<string name="payment">মূল্যপরিশোধ</string>
<string name="cashu">ক্যাশু টোকেন</string>
<string name="cashu_redeem">খালাস করুন</string>
<string name="no_lightning_address_set">কোনো বিজলি-ঠিকানা সেট করা নেই</string>
<string name="copied_token_to_clipboard">টোকেনটি ক্লিপবোর্ডে কপি করা হয়েছে</string>
<string name="live_stream_live_tag">লাইভ</string>
<string name="live_stream_offline_tag">অফলাইন</string>
<string name="live_stream_ended_tag">সমাপ্ত</string>
<string name="live_stream_planned_tag">সময়সূচি নির্ধারিত</string>
<string name="live_stream_is_offline">লাইভস্ট্রিমটি অফলাইন আছে</string>
<string name="live_stream_has_ended">লাইভস্ট্রিম সমাপ্ত</string>
<string name="are_you_sure_you_want_to_log_out">লগ আউট করলে আপনার সমস্ত স্থানীয় তথ্য মুছে যাবে। অ্যাকাউন্ট সুরক্ষিত রাখতে আপনার ব্যক্তিগত চাবিটি নিরাপদে সংরক্ষিত আছে কিনা নিশ্চিত হোন। সামনে আগাতে চান?</string>
<string name="followed_tags">অনুসৃত ট্যাগগুলি</string>
<string name="relay_setup">রিলেগুলি</string>
<string name="discover_live">লাইভ</string>
<string name="discover_community">কম্যুনিটি</string>
<string name="discover_chat">বার্তালাপগুলি</string>
<string name="community_approved_posts">অনুমোদিত পোস্টগুলি</string>
<string name="groups_no_descriptor">এই দলটির কোনো বিবরণ কিংবা নীতিমালা নেই। এগুলো যুক্ত করতে দলনেতার সাথে কথা বলুন</string>
<string name="community_no_descriptor">এই গোষ্ঠীর কোনো বিবরণ কিংবা নীতিমালা নেই। এগুলো যুক্ত করতে এর মালিকের সাথে কথা বলুন</string>
<string name="add_sensitive_content_label">সংবেদনশীল আধেয়</string>
<string name="add_sensitive_content_description">সংবেদনশীল আধেয় দেখানোর আগে এ সম্পর্কিত সতর্কতা প্রদর্শন করে</string>
<string name="settings">সেটিংস</string>
<string name="connectivity_type_always">সবসময়</string>
<string name="connectivity_type_wifi_only">শুধুমাত্র ওয়াইফাইতে</string>
<string name="connectivity_type_never">কখনো না</string>
<string name="system">সিস্টেম</string>
<string name="light">উজ্জ্বল</string>
<string name="dark">আঁধারি</string>
<string name="application_preferences">অ্যাপ্লিকেশনের পছন্দসমূহ</string>
<string name="language">ভাষা</string>
<string name="theme">থিম</string>
<string name="automatically_load_images_gifs">ছবির পূর্বরূপ</string>
<string name="automatically_play_videos">ভিডিও প্লেব্যাক</string>
<string name="automatically_show_url_preview">URL-এর পূর্বরূপ</string>
<string name="load_image">ছবিটি লোড করুন</string>
<string name="spamming_users">স্প্যামাররা</string>
<string name="muted_button">মৌন করে রাখা। এটি তুলে নিতে ক্লিক করুন</string>
<string name="mute_button">শব্দ চালু আছে। মৌন করতে ক্লিক করুন</string>
<string name="search_button">স্থানীয় এবং দূরবর্তী রেকর্ড অনুসন্ধান করুন</string>
<string name="nip05_verified">নস্টার ঠিকানাটি যাচাই করা হয়েছে</string>
<string name="nip05_failed">নস্টার ঠিকানাটি যাচাই করা যায় নি</string>
<string name="nip05_checking">নস্টার ঠিকানাটি পরীক্ষা করে দেখা হচ্ছে</string>
<string name="select_deselect_all">সবগুলিকে নির্বাচন/অনির্বাচন করুন</string>
<string name="default_relays">পূর্ব-নির্ধারিত</string>
<string name="select_a_relay_to_continue">চালিয়ে যেতে একটি রিলে নির্বাচন করুন</string>
<string name="zap_forward_title">জ্যাপটি পাঠান:</string>
<string name="zap_forward_explainer">সহায়তাকারী ক্লায়েন্টরা আপনার পরিবর্তে নিচের বিজলি-ঠিকানায় বা ব্যবহারকারীর প্রোফাইলে জ্যাপ পাঠাবে</string>
<string name="geohash_title">আপনার অবস্থান এভাবে প্রকাশ করুন </string>
<string name="geohash_explainer">পোস্টে আপনার অবস্থানের একটি জিওহ্যাশ যোগ করে। জনসাধারণ জানবে যে আপনি বর্তমান অবস্থান থেকে ৫ কিলোমিটার (৩ মাইল) এর মধ্যে আছেন</string>
<string name="add_sensitive_content_explainer">আপনার পেশকৃত আধেয় দেখানোর আগে সংবেদনশীল আধেয়-সতর্কতা জারি করে। এটি সেসকল আধেয়র জন্য আদর্শ যেখানে NSFW উপাদান কিংবা এমনকিছু থাকতে পারে যা কারো কারো জন্য আপত্তিকর কিংবা সমস্যাজনক মনে হতে পারে।</string>
<string name="new_feature_nip24_might_not_be_available_title">নতুন ফিচার</string>
<string name="new_feature_nip24_might_not_be_available_description">এই মোড সক্রিয় করার জন্য অ্যামেথিস্টকে একটি NIP-24 বার্তা পাঠাতে হবে। (GiftWrapped, সিল করা সরাসরি এবং দলগত বার্তা)। NIP-24 নতুন এবং বেশিরভাগ ক্লায়েন্ট এখনও এটি বাস্তবায়ন করেনি। নিশ্চিত করুন যে প্রাপক একটি সামঞ্জস্যপূর্ণ ক্লায়েন্ট ব্যবহার করছে।</string>
<string name="new_feature_nip24_activate">সক্রিয় করুন</string>
<string name="messages_create_public_chat">সর্বজনীন</string>
<string name="messages_new_message">ব্যক্তিগত</string>
<string name="messages_new_message_to">প্রতি</string>
<string name="messages_new_message_subject">বিষয়</string>
<string name="messages_new_message_subject_caption">আলোচনার বিষয়</string>
<string name="messages_new_message_to_caption">"\@User1, @User2, @User3"</string>
<string name="messages_group_descriptor">এই দলের সদস্যরা</string>
<string name="messages_new_subject_message">সদস্যদের নিকট ব্যাখ্যা</string>
<string name="messages_new_subject_message_placeholder">নতুন লক্ষ্যের জন্য নাম পরিবর্তন করা হচ্ছে।</string>
<string name="paste_from_clipboard">ক্লিপবোর্ড থেকে পেস্ট করুন</string>
<string name="language_description">অ্যাপের ইন্টারফেসের জন্য</string>
<string name="theme_description">আঁধারি, উজ্জ্বল কিংবা সিস্টেমের থিম</string>
<string name="automatically_load_images_gifs_description">স্বয়ংক্রিয়ভাবে ছবি এবং GIF লোড করুন</string>
<string name="automatically_play_videos_description">স্বয়ংক্রিয়ভাবে ভিডিও এবং GIF চালু করুন</string>
<string name="automatically_show_url_preview_description">URL এর পূর্বরূপ দেখান</string>
<string name="load_image_description">কখন ছবি লোড করতে হবে</string>
<string name="copy_url_to_clipboard">URL ক্লিপবোর্ডে কপি করুন</string>
<string name="copy_the_note_id_to_the_clipboard">নোট আইডি ক্লিপবোর্ডে কপি করুন</string>
<string name="created_at">যেখানে নির্মিত</string>
<string name="rules">নিয়ম</string>
<string name="login_with_external_signer">Amber দিয়ে লগইন করুন</string>
<string name="status_update">আপনার স্ট্যাটাস আপডেট করুন</string>
<string name="lightning_wallets_not_found">Error parsing error message</string>
<string name="poll_zap_value_min_max_explainer">জ্যাপের পরিমাণ দ্বারা ভোটসংখ্যা গোণা হয়। আপনি স্প্যামার এড়াতে একটি সর্বনিম্ন পরিমাণ এবং একটি বড়ো জ্যাপার দ্বারা পোল দখল এড়াতে সর্বোচ্চ পরিমাণ স্যাট নির্ধারণ করতে পারেন। প্রতিটি ভোটের মূল্য একই পরিমাণে রয়েছে তা নিশ্চিত করতে উভয় ক্ষেত্রেই একই পরিমাণ ব্যবহার করুন। যেকোনো পরিমাণ গ্রহণ করতে এটি খালি ছেড়ে দিন।</string>
<string name="error_dialog_zap_error">জ্যাপ পাঠানো যায় নি</string>
<string name="error_dialog_talk_to_user">ব্যবহারকারীকে বার্তা দিন</string>
<string name="error_dialog_button_ok">ঠিক আছে</string>
<string name="relay_information_document_error_assemble_url">%1$s: %2$s পৌঁছাতে ব্যর্থ</string>
<string name="relay_information_document_error_reach_server">%1$s: %2$s পৌঁছাতে ব্যর্থ</string>
<string name="relay_information_document_error_parse_result">%1$s: %2$s থেকে ফলাফল পার্স করা যায় নি</string>
<string name="relay_information_document_error_http_status">%1$s কোড দিয়ে ব্যর্থ হয়েছে %2$s</string>
<string name="active_for">এর জন্য সক্রিয় করুন: </string>
<string name="active_for_home">নীড়</string>
<string name="active_for_msg">বার্তাসমূহ</string>
<string name="active_for_chats">আলাপচারিতা</string>
<string name="active_for_global">বৈশ্বিক</string>
<string name="active_for_search">অনুসন্ধান করুন</string>
<string name="zap_split_title">জ্যাপগুলি ভাগ করুন এবং পাঠান</string>
<string name="zap_split_explainer">সমর্থিত ক্লায়েন্টরা জ্যাপ ভাগ করবে এবং আপনার পরিবর্তে এখানে সংযুক্ত ব্যবহারকারীদেরকে পাঠাবে</string>
<string name="zap_split_serarch_and_add_user">ব্যবহারকারীদের অনুসন্ধান করুন এবং যুক্ত করুন</string>
<string name="zap_split_serarch_and_add_user_placeholder">ইউজারনেম কিংবা প্রদর্শিত নাম দিন</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">%1$s ব্যবহারকারীর স্যাট গ্রহণের উপযোগী কোনো বিজলি ঠিকানা সেট করা নেই</string>
<string name="zap_split_weight">শতাংশ</string>
<string name="zap_split_weight_placeholder">২৫</string>
<string name="splitting_zaps_with">এর সাথে জ্যাপ ভাগ করা হচ্ছে</string>
<string name="forwarding_zaps_to">একে জ্যাপ পাঠানো হচ্ছে</string>
<string name="lightning_wallets_not_found2">বিজলি-ওয়ালেট পাওয়া যায় নি</string>
<string name="paid">পরিশোধিত</string>
<string name="wallet_number">ওয়ালেট %1$s</string>
<string name="error_opening_external_signer">স্বাক্ষরের অ্যাপ খোলা যাচ্ছে না</string>
<string name="sign_request_rejected">স্বাক্ষরের অনুরোধ প্রত্যাখ্যান করা হয়েছে</string>
</resources>
+10
View File
@@ -463,4 +463,14 @@
<string name="poll_zap_value_min_max_explainer">Hlasy jsou váženy podle hodnoty zapu. Můžete nastavit minimální částku, abyste se vyhnuli spammerům, a maximální částku, abyste zabránili velkým zapperům, kteří by mohli ovládnout hlasování. Použijte stejnou částku v obou polích, aby byla hodnota každého hlasu stejná. Nechte prázdné pro přijetí libovolné částky.</string>
<string name="error_dialog_zap_error">Nelze odeslat zap</string>
<string name="error_dialog_talk_to_user">Poslat zprávu uživateli</string>
<string name="relay_information_document_error_assemble_url">Nepodařilo se dosáhnout %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Nepodařilo se dosáhnout %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">Nepodařilo se zpracovat výsledek z %1$s: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s selhalo s kódem %2$s</string>
<string name="active_for">Aktivní pro: </string>
<string name="active_for_home">Domů</string>
<string name="active_for_msg">Zprávy</string>
<string name="active_for_chats">Chaty</string>
<string name="active_for_global">Celosvětové</string>
<string name="active_for_search">Hledání</string>
</resources>
+8
View File
@@ -461,4 +461,12 @@ anz der Bedingungen ist erforderlich</string>
<string name="poll_zap_value_min_max_explainer">Die Abstimmungen werden nach der Höhe des Zaps gewichtet. Sie können einen Mindestbetrag festlegen, um Spam zu verhindern, und einen Höchstbetrag, um zu verhindern, dass große Zapper die Abstimmung dominieren. Verwenden Sie denselben Betrag in beiden Feldern, um sicherzustellen, dass jeder Stimme der gleiche Wert zukommt. Lassen Sie es leer, um jeden Betrag zu akzeptieren.</string>
<string name="error_dialog_zap_error">Zap konnte nicht gesendet werden</string>
<string name="error_dialog_talk_to_user">Mit dem Benutzer kommunizieren</string>
<string name="relay_information_document_error_assemble_url">Konnte %1$s nicht erreichen: %2$s</string>
<string name="relay_information_document_error_reach_server">Konnte %1$s nicht erreichen: %2$s</string>
<string name="relay_information_document_error_parse_result">Konnte das Ergebnis von %1$s nicht verarbeiten: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s fehlgeschlagen mit Code %2$s</string>
<string name="active_for">Aktiv für: </string>
<string name="active_for_home">Startseite</string>
<string name="active_for_msg">Nachrichten</string>
<string name="active_for_search">Suche</string>
</resources>
@@ -474,4 +474,14 @@
<string name="error_dialog_zap_error">Zapin lähettäminen ei onnistunut</string>
<string name="error_dialog_talk_to_user">Viesti käyttäjälle</string>
<string name="error_dialog_button_ok">Ok</string>
<string name="relay_information_document_error_assemble_url">Kohdetta %1$s ei tavoitettu: %2$s</string>
<string name="relay_information_document_error_reach_server">Kohdetta %1$s ei tavoitettu: %2$s</string>
<string name="relay_information_document_error_parse_result">Tuloksen jäsentäminen epäonnistui %1$s: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s epäonnistui koodilla %2$s</string>
<string name="active_for">Aktiivinen kohteelle: </string>
<string name="active_for_home">Koti</string>
<string name="active_for_msg">YVt</string>
<string name="active_for_chats">Keskustelut</string>
<string name="active_for_global">Yleinen</string>
<string name="active_for_search">Haku</string>
</resources>
+22
View File
@@ -474,4 +474,26 @@
<string name="error_dialog_zap_error">Impossible d\'envoyer un zap</string>
<string name="error_dialog_talk_to_user">Contacter l\'Utilisateur</string>
<string name="error_dialog_button_ok">Ok</string>
<string name="relay_information_document_error_assemble_url">Impossible d\'atteindre %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Impossible d\'atteindre %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">Impossible d\'analyser le résultat de %1$s: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s a échoué avec le code %2$s</string>
<string name="active_for">Actif pour : </string>
<string name="active_for_home">Accueil</string>
<string name="active_for_msg">MPs</string>
<string name="active_for_chats">Salons</string>
<string name="active_for_global">Général</string>
<string name="active_for_search">Rechercher</string>
<string name="zap_split_title">Diviser et Transférer des Zaps</string>
<string name="zap_split_explainer">Les clients supportés vont diviser et transférer les zaps aux utilisateurs ajoutés ici au lieu du vôtre</string>
<string name="zap_split_serarch_and_add_user">Rechercher et Ajouter un Utilisateur</string>
<string name="zap_split_serarch_and_add_user_placeholder">Nom d\'utilisateur ou nom affiché</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur %1$s n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string>
<string name="zap_split_weight">Pourcentage</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Diviser les zaps avec</string>
<string name="forwarding_zaps_to">Transférer les zaps à</string>
<string name="lightning_wallets_not_found2">Portefeuilles Lightning introuvables</string>
<string name="paid">Payé</string>
<string name="wallet_number">Portefeuille %1$s</string>
</resources>
+11 -1
View File
@@ -468,10 +468,20 @@
<string name="copy_the_note_id_to_the_clipboard">A bejegyzésazonosító vágólapra másolása</string>
<string name="created_at">Létrehozva</string>
<string name="rules">Szabályok</string>
<string name="status_update">Állapotod változtatása</string>
<string name="status_update">Állapotod megjelenítése</string>
<string name="lightning_wallets_not_found">Hiba a hibaüzenet elemzésekor</string>
<string name="poll_zap_value_min_max_explainer">A szavazatokat a Zap-ek összegével súlyozzuk. Beállíthatsz egy minimális összeget, hogy a kéretlen leveleket elkerüld, és egy maximális összeget annak elkerülésére, hogy a szavazás feletti irányítást a nagy Zapperek vegyék át. Mindkét mezőben ugyanazt az összeget használd, hogy minden szavazat azonos értéket kapjon. Bármilyen összeg elfogadásához, hagyjd üresen.</string>
<string name="error_dialog_zap_error">Zap küldése nem sikerült</string>
<string name="error_dialog_talk_to_user">Üzenet a Felhasználónak</string>
<string name="error_dialog_button_ok">Ok</string>
<string name="relay_information_document_error_assemble_url">Nem sikerült elérni %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Nem sikerült elérni %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">Nem sikerült az eredmény elemzése a %1$s: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s hiba a következő kóddal %2$s</string>
<string name="active_for">Aktív: </string>
<string name="active_for_home">Főoldal</string>
<string name="active_for_msg">PÜk</string>
<string name="active_for_chats">Chat</string>
<string name="active_for_global">Globális</string>
<string name="active_for_search">Keresés</string>
</resources>
+370 -23
View File
@@ -1,54 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="point_to_the_qr_code">Inquadra il QR Code</string>
<string name="point_to_the_qr_code">Inquadra il codice QR</string>
<string name="show_qr">Mostra QR</string>
<string name="profile_image">Foto profilo</string>
<string name="scan_qr">Scansiona QR</string>
<string name="show_anyway">Mostra comunque</string>
<string name="post_was_flagged_as_inappropriate_by">Post segnalato da</string>
<string name="post_not_found">L\'evento sta caricando o non può essere trovato nei tuoi relay</string>
<string name="post_not_found">L\'evento sta caricando o non può essere trovato nei tuoi relè</string>
<string name="channel_image">Foto del canale</string>
<string name="referenced_event_not_found">L\'evento referenziato non è stato trovato</string>
<string name="could_not_decrypt_the_message">Impossibile decrittare il messaggio</string>
<string name="referenced_event_not_found">L\'evento di riferimento non è stato trovato</string>
<string name="could_not_decrypt_the_message">Impossibile decriptare il messaggio</string>
<string name="group_picture">Foto del gruppo</string>
<string name="explicit_content">Contenuto esplicito</string>
<string name="spam">Spam</string>
<string name="impersonation">Impersonazione</string>
<string name="impersonation">Impersonificazione</string>
<string name="illegal_behavior">Comportamento scorretto</string>
<string name="unknown">Sconosciuto</string>
<string name="relay_icon">Icona del relay</string>
<string name="relay_icon">Icona del relè</string>
<string name="unknown_author">Autore sconosciuto</string>
<string name="copy_text">Copia testo</string>
<string name="copy_user_pubkey">Copia ID autore</string>
<string name="copy_note_id">Copia ID nota</string>
<string name="copy_user_pubkey">Copia ID dell\'autore</string>
<string name="copy_note_id">Copia ID della nota</string>
<string name="broadcast">Trasmissione</string>
<string name="request_deletion">Richiedi cancellazione</string>
<string name="block_report">Blocca / Segnala</string>
<string name="block_hide_user"><![CDATA[Blocca e Nascondi Utente]]></string>
<string name="report_spam_scam">Segnala Spam / Truffa</string>
<string name="report_impersonation">Segnala impersonatore</string>
<string name="report_impersonation">Segnala impersonificatore</string>
<string name="report_explicit_content">Segnala contenuto esplicito</string>
<string name="report_illegal_behaviour">Segnala comportamento inapproriato</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Accedi con una Chiave Privata per rispondere</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Accedi con una Chiave Privata per ricondividere i post</string>
<string name="login_with_a_private_key_to_like_posts">Accedi con una Chiave Privata per poter mettere mi piace ai post</string>
<string name="no_zap_amount_setup_long_press_to_change">Nessun importo per gli Zap impostato. Tieni premuto per aggiungerne</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Accedi con una Chiave Privata per poter inviare Zaps</string>
<string name="login_with_a_private_key_to_be_able_to_follow">Accedi con una Chiave Privata per seguire</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">Accedi con una Chiave Privata per smettere di seguire</string>
<string name="zaps">Zaps</string>
<string name="view_count">Visualizzazioni</string>
<string name="boost">Boost</string>
<string name="boosted">ricondiviso</string>
<string name="report_illegal_behaviour">Segnala comportamento scorretto</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Accedi con una chiave privata per poter rispondere</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Accedi con una chiave privata per poter condividere i post</string>
<string name="login_with_a_private_key_to_like_posts">Accedi con una chiave privata per mettere mi piace ai post</string>
<string name="no_zap_amount_setup_long_press_to_change">Nessun ammontare per gli Zap impostato. Tieni premuto per cambiare</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Accedi con una chiave privata per poter inviare gli Zap</string>
<string name="login_with_a_private_key_to_be_able_to_follow">Accedi con una chiave privata per poter seguire</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">Accedi con una chiave privata per poter smettere di seguire</string>
<string name="zaps">Zap</string>
<string name="view_count">Mostra conteggio</string>
<string name="boost">Condividi</string>
<string name="boosted">condiviso</string>
<string name="quote">Cita</string>
<string name="new_amount_in_sats">Nuovo importo in Sats</string>
<string name="add">Aggiungi</string>
<string name="replying_to">"rispondendo a"</string>
<string name="and">" e "</string>
<string name="in_channel">"nel canale "</string>
<string name="profile_banner">Immagine copertina</string>
<string name="profile_banner">Immagine di copertina</string>
<string name="payment_successful">Pagamento effettuato</string>
<string name="error_parsing_error_message">Errore durante l\'interpretazione del messaggio d\'errore</string>
<string name="error_parsing_error_message">Errore analisi messaggio d\'errore</string>
<string name="following">" Seguiti"</string>
<string name="followers">" Seguaci"</string>
<string name="profile">Profilo</string>
@@ -89,6 +89,56 @@
<string name="display_name">Nome Visualizzato</string>
<string name="my_display_name">Il mio nome visualizzato</string>
<string name="username">Username</string>
<string name="my_username">Il mio nome utente</string>
<string name="about_me">Su di me</string>
<string name="avatar_url">Immagine del profilo URL</string>
<string name="banner_url">Copertina URL</string>
<string name="website_url">Sito web URL</string>
<string name="ln_address">Indirizzo LN</string>
<string name="ln_url_outdated">LN URL (obsoleto)</string>
<string name="image_saved_to_the_gallery">Immagine salvata nella galleria</string>
<string name="failed_to_save_the_image">Salvataggio dell\'immagine non riuscito</string>
<string name="upload_image">Carica immagine</string>
<string name="uploading">Caricamento…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utente non dispone di un indirizzo Lightning per ricevere sats</string>
<string name="reply_here">"rispondi qui.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copia l\'ID della nota negli appunti per la condivisione in Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">Copia l\'ID del canale (Nota) negli appunti</string>
<string name="edits_the_channel_metadata">Modifica i metadati del canale</string>
<string name="join">Unisciti</string>
<string name="known">Conosciuto</string>
<string name="new_requests">Nuove richieste</string>
<string name="blocked_users">Utenti bloccati</string>
<string name="new_threads">Nuove Discussioni</string>
<string name="conversations">Conversazioni</string>
<string name="notes">Note</string>
<string name="replies">Risposte</string>
<string name="follows">"Seguiti"</string>
<string name="reports">"Segnalazioni"</string>
<string name="more_options">Più Opzioni</string>
<string name="relays">" Relè"</string>
<string name="website">Sito web</string>
<string name="lightning_address">Indirizzo Lightning</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Copia l\'ID Nsec (la password) negli appunti per il backup</string>
<string name="copy_private_key_to_the_clipboard">Copia la chiave segreta negli appunti</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Copia la chiave pubblica negli appunti per la condivisione</string>
<string name="copy_public_key_npub_to_the_clipboard">Copia la chiave pubblica (Npub) negli appunti</string>
<string name="send_a_direct_message">Invia un messaggio diretto</string>
<string name="edits_the_user_s_metadata">Modifica i metadati dell\'utente</string>
<string name="follow">Segui</string>
<string name="follow_back">Segui anche tu</string>
<string name="unblock">Sblocca</string>
<string name="copy_user_id">Copia ID Utente</string>
<string name="unblock_user">Sblocca utente</string>
<string name="npub_hex_username">"npub, nome utente, testo"</string>
<string name="clear">Pulisci</string>
<string name="app_logo">Logo dell\'app</string>
<string name="nsec_npub_hex_private_key">nsec.. o npub..</string>
<string name="show_password">Mostra password</string>
<string name="hide_password">Nascondi password</string>
<string name="invalid_key">Chiave non valida</string>
<string name="i_accept_the">"Accetto il/lo/la/i/gli/le "</string>
<string name="terms_of_use">Condizioni di utilizzo</string>
<string name="acceptance_of_terms_is_required">L\'accettazione dei termini è obbligatoria</string>
<string name="key_is_required">La chiave è obbligatoria</string>
<string name="login">Accesso</string>
@@ -114,6 +164,7 @@
<string name="translations_auto">Auto</string>
<string name="translations_translated_from">tradotto da</string>
<string name="translations_to">a</string>
<string name="translations_show_in_lang_first">Mostra prima in %1$s</string>
<string name="translations_always_translate_to_lang">Traduci sempre in %1$s</string>
<string name="translations_never_translate_from_lang">Non tradurre mai da %1$s</string>
<string name="nip_05">Indirizzo Nostr</string>
@@ -137,4 +188,300 @@
\n- Gli sviluppatori di Amethyst non chiederanno **mai** la tua chiave privata.
\n- Mantieni **sempre** al sicuro un backup della tua chiave privata per recuperare l\'account. Ti suggeriamo di usare un password manager.
</string>
<string name="secret_key_copied_to_clipboard">Chiave segreta (nsec) copiata negli appunti</string>
<string name="copy_my_secret_key">Copia la mia chiave segreta</string>
<string name="biometric_authentication_failed">Autenticazione fallita</string>
<string name="biometric_error">Errore</string>
<string name="badge_created_by">"Creato da %1$s"</string>
<string name="badge_award_image_for">"Immagine premio badge per %1$s"</string>
<string name="new_badge_award_notif">Hai ricevuto un nuovo premio Badge</string>
<string name="award_granted_to">Premio badge assegnato a</string>
<string name="copied_note_text_to_clipboard">Testo della nota copiato negli appunti</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">\@npub dell\'autore copiata negli appunti</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">ID della nota copiato (@note1) negli appunti</string>
<string name="select_text_dialog_top">Seleziona testo</string>
<string name="private_conversation_notification">"&lt;Incapace di decriptare il messaggio privato&gt;\n\nSei stato citato in una conversazione privata/criptata tra %1$s e %2$s."</string>
<string name="account_switch_add_account_dialog_title">Aggiungi un nuovo profilo</string>
<string name="drawer_accounts">Profili</string>
<string name="account_switch_select_account">Seleziona profilo</string>
<string name="account_switch_add_account_btn">Aggiungi un nuovo profilo</string>
<string name="account_switch_active_account">Profilo attivo</string>
<string name="account_switch_has_private_key">Ha la chiave privata</string>
<string name="account_switch_pubkey_only">Solo lettura, nessuna chiave privata</string>
<string name="back">Indietro</string>
<string name="quick_action_select">Seleziona</string>
<string name="quick_action_share_browser_link">Condividi Collegamento Browser</string>
<string name="quick_action_share">Condividi</string>
<string name="quick_action_copy_user_id">ID dell\'autore</string>
<string name="quick_action_copy_note_id">ID della nota</string>
<string name="quick_action_copy_text">Copia testo</string>
<string name="quick_action_delete">Elimina</string>
<string name="quick_action_unfollow">Smetti di seguire</string>
<string name="quick_action_follow">Segui</string>
<string name="quick_action_request_deletion_alert_title">Richiedi eliminazione</string>
<string name="quick_action_request_deletion_alert_body">Amethyst richiederà che la tua nota sia cancellata dai relè a cui sei attualmente connesso. Non vi è alcuna garanzia che la tua nota venga cancellata definitivamente da quei relè o da altri relè dove può essere memorizzata.</string>
<string name="quick_action_block_dialog_btn">Blocca</string>
<string name="quick_action_delete_dialog_btn">Elimina</string>
<string name="quick_action_block">Blocca</string>
<string name="quick_action_report">Segnala</string>
<string name="quick_action_delete_button">Elimina</string>
<string name="quick_action_dont_show_again_button">Non mostrare più</string>
<string name="report_dialog_spam">Spam o truffe</string>
<string name="report_dialog_profanity">Blasfemia o comportamento odioso</string>
<string name="report_dialog_impersonation">Impersonificazione malevola</string>
<string name="report_dialog_nudity">Nudità o contenuto grafico</string>
<string name="report_dialog_illegal">Comportamento scorretto</string>
<string name="report_dialog_blocking_a_user">Il blocco di un utente nasconderà il suo contenuto nella tua app. Le tue note sono ancora visibili pubblicamente, anche per le persone che blocchi. Gli utenti bloccati sono elencati nella schermata Filtri di sicurezza.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blocca e Nascondi Utente]]></string>
<string name="report_dialog_report_btn">Segnala un abuso</string>
<string name="report_dialog_reminder_public">Tutte le segnalazioni pubblicate saranno visibili pubblicamente.</string>
<string name="report_dialog_additional_reason_placeholder">Facoltativamente fornisci un contesto aggiuntivo sulla tua segnalazione…</string>
<string name="report_dialog_additional_reason_label">Contesto Aggiuntivo</string>
<string name="report_dialog_select_reason_label">Motivo</string>
<string name="report_dialog_select_reason_placeholder">Seleziona un motivo…</string>
<string name="report_dialog_post_report_btn">Pubblica una segnalazione</string>
<string name="report_dialog_title">Blocca e Segnala</string>
<string name="block_only">Blocca</string>
<string name="bookmarks">Segnalibri</string>
<string name="private_bookmarks">Segnalibri privati</string>
<string name="public_bookmarks">Segnalibri pubblici</string>
<string name="add_to_private_bookmarks">Aggiungi ai segnalibri privati</string>
<string name="add_to_public_bookmarks">Aggiungi ai segnalibri pubblici</string>
<string name="remove_from_private_bookmarks">Rimuovi dai segnalibri privati</string>
<string name="remove_from_public_bookmarks">Rimuovi dai segnalibri pubblici</string>
<string name="wallet_connect_service">Servizio di Connessione Portafoglio</string>
<string name="wallet_connect_service_explainer">Autorizza un Nostr Secret a pagare gli zaps senza lasciare l\'app. Mantieni il segreto al sicuro e usa un relè privato se possibile</string>
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
<string name="wallet_connect_service_relay">Wallet Connect Relè</string>
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
<string name="wallet_connect_service_show_secret">Mostra chiave segreta</string>
<string name="wallet_connect_service_secret_placeholder">nsec / chiave privata esadecimale</string>
<string name="pledge_amount_in_sats">Importo in pegno in sats</string>
<string name="post_poll">Pubblica sondaggio</string>
<string name="poll_heading_required">Campi obbligatori:</string>
<string name="poll_zap_recipients">Destinatari zap</string>
<string name="poll_primary_description">Descrizione principale del sondaggio…</string>
<string name="poll_option_index">Opzione %s</string>
<string name="poll_option_description">Descrizione opzione sondaggio</string>
<string name="poll_heading_optional">Campi facoltativi:</string>
<string name="poll_zap_value_min">Zap minimo</string>
<string name="poll_zap_value_max">Zap massimo</string>
<string name="poll_consensus_threshold">Consenso</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Chiudi dopo</string>
<string name="poll_closing_time_days">giorni</string>
<string name="poll_is_closed">Il sondaggio è chiuso a nuovi voti</string>
<string name="poll_zap_amount">Quantità in zap</string>
<string name="one_vote_per_user_on_atomic_votes">Per questo tipo di sondaggio è consentito solo un voto per utente</string>
<string name="looking_for_event">"Cercando l'evento %1$s"</string>
<string name="custom_zaps_add_a_message">Aggiungi un messaggio pubblico</string>
<string name="custom_zaps_add_a_message_private">Aggiungi un messaggio privato</string>
<string name="custom_zaps_add_a_message_nonzap">Aggiungi un messaggio di fattura</string>
<string name="custom_zaps_add_a_message_example">Grazie per tutto il tuo lavoro!</string>
<string name="lightning_create_and_add_invoice">Crea e Aggiungi</string>
<string name="poll_author_no_vote">Gli autori del sondaggio non possono votare nei propri sondaggi.</string>
<string name="hash_verification_passed">Questo contenuto è lo stesso da quando è stato pubblicato</string>
<string name="hash_verification_failed">Questo contenuto è cambiato. L\'autore potrebbe non aver visto o approvato la modifica</string>
<string name="content_description_add_image">Aggiungi Immagine</string>
<string name="content_description_add_video">Aggiungi Video</string>
<string name="content_description_add_document">Aggiungi Documento</string>
<string name="add_content">Aggiungi al Messaggio</string>
<string name="content_description">Descrizione dei contenuti</string>
<string name="content_description_example">Una barca blu in una spiaggia di sabbia bianca al tramonto</string>
<string name="zap_type">Tipo di Zap</string>
<string name="zap_type_explainer">Tipo di Zap per tutte le opzioni</string>
<string name="zap_type_public">Pubblico</string>
<string name="zap_type_public_explainer">Tutti possono vedere la transazione e il messaggio</string>
<string name="zap_type_private">Privato</string>
<string name="zap_type_private_explainer">Mittente e Destinatario possono vedersi e leggere il messaggio</string>
<string name="zap_type_anonymous">Anonimo</string>
<string name="zap_type_anonymous_explainer">Il destinatario e il pubblico non sanno chi ha inviato il pagamento</string>
<string name="zap_type_nonzap">Non-Zap</string>
<string name="zap_type_nonzap_explainer">Nessuna traccia in Nostr, solo in Lightning</string>
<string name="file_server">File Server</string>
<string name="zap_forward_lnAddress">Indirizzo LN o @User</string>
<string name="upload_server_imgur">imgur.com - fidato</string>
<string name="upload_server_imgur_explainer">Imgur può modificare il file</string>
<string name="upload_server_nostrimg">nostrimg.com - fidato</string>
<string name="upload_server_nostrimg_explainer">NostrImg può modificare il file</string>
<string name="upload_server_nostrbuild">nostr.build - fidato</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build può modificare il file</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - fidato</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev può modificare il file</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - fidato</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me può modificare il file</string>
<string name="upload_server_imgur_nip94">Imgur verificabile (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Controlla se Imgur ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
<string name="upload_server_nostrimg_nip94">NostrImg verificabile (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Controlla se NostrImg ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
<string name="upload_server_nostrbuild_nip94">Nostr.build verificabile (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Controlla se Nostr.build ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
<string name="upload_server_nostrfilesdev_nip94">Nostrfiles.dev verificabile (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Controlla se Nostrfiles.dev ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
<string name="upload_server_nostrcheckme_nip94">Nostrcheck.me verificabile (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Controlla se Nostrcheck.me ha modificato il file. Nuovo NIP: altri client potrebbero non vederlo</string>
<string name="upload_server_relays_nip95">I tuoi relè (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">I file sono ospitati dai tuoi relè. Nuovo NIP: controlla se lo supportano</string>
<string name="connect_via_tor_short">Configurazione Tor/Orbot</string>
<string name="connect_via_tor">Connettiti tramite la tua configurazione di Orbot</string>
<string name="do_you_really_want_to_disable_tor_title">Disconnettere dal tuo Orbot/Tor?</string>
<string name="do_you_really_want_to_disable_tor_text">I tuoi dati verranno immediatamente trasferiti nella rete regolare</string>
<string name="yes"></string>
<string name="no">No</string>
<string name="follow_list_selection">Segui Lista</string>
<string name="follow_list_kind3follows">Tutti i seguiti</string>
<string name="follow_list_global">Globale</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Connettiti tramite Tor con Orbot
\n\n1. Installa [Orbot](https://play.google. om/store/apps/details?id=org.torproject.android)
\n2. Avvia Orbot
\n3. In Orbot, controlla la porta Socks. Il valore predefinito utilizza 9050
\n4. Se necessario, cambiare la porta in Orbot
\n5. Configura la porta Socks in questa schermata
\n6. Premi il pulsante Attiva per usare Orbot come proxy
</string>
<string name="orbot_socks_port">Porta Socks di Orbot</string>
<string name="invalid_port_number">Numero della porta non valido</string>
<string name="use_orbot">Usa Orbot</string>
<string name="disconnect_from_your_orbot_setup">Disconnetti Tor/Orbot</string>
<string name="app_notification_dms_channel_name">Messaggi privati</string>
<string name="app_notification_dms_channel_description">Notifica quando arriva un messaggio privato</string>
<string name="app_notification_zaps_channel_name">Zaps Ricevuti</string>
<string name="app_notification_zaps_channel_description">Notifica quando qualcuno ti invia uno zap</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">Da %1$s</string>
<string name="app_notification_zaps_channel_message_for">per %1$s</string>
<string name="reply_notify">Notificare: </string>
<string name="channel_list_join_conversation">Unisciti Alla Conversazione</string>
<string name="channel_list_user_or_group_id">ID utente o di gruppo</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent o esadecimale</string>
<string name="channel_list_create_channel">Crea</string>
<string name="channel_list_join_channel">Unisciti</string>
<string name="today">Oggi</string>
<string name="content_warning">Avviso sui contenuti</string>
<string name="content_warning_explanation">Questo post contiene contenuti sensibili che alcune persone possono trovare offensivi o disturbanti</string>
<string name="content_warning_hide_all_sensitive_content">Nascondi sempre i contenuti sensibili</string>
<string name="content_warning_show_all_sensitive_content">Mostra sempre i contenuti sensibili</string>
<string name="content_warning_see_warnings">Mostra sempre gli avvisi sui contenuti</string>
<string name="recommended_apps">Consigliati: </string>
<string name="filter_spam_from_strangers">Filtra spam dagli sconosciuti</string>
<string name="warn_when_posts_have_reports_from_your_follows">Avvisa quando i post hanno segnalazioni da parte di chi segui</string>
<string name="new_reaction_symbol">Nuovo Simbolo Di Reazione</string>
<string name="no_reaction_type_setup_long_press_to_change">Nessun tipo di reazione selezionato. Premi a lungo per cambiare</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">Aggiunge una quantità di sats da raccogliere per questo post. I client che supportano questa funzione la mostreranno con una barra di avanzamento per incentivare le donazioni</string>
<string name="zapraiser_target_amount_in_sats">Ammontare dell\'obiettivo in sats</string>
<string name="sats_to_complete">Zapraiser a %1$s. %2$s sats all\'obiettivo</string>
<string name="read_from_relay">Leggi dal Relè</string>
<string name="write_to_relay">Scrivi su Relè</string>
<string name="an_error_occurred_trying_to_get_relay_information">Si è verificato un errore nel tentativo di ottenere le informazioni del relè da %1$s</string>
<string name="owner">Proprietario</string>
<string name="version">Versione</string>
<string name="software">Software</string>
<string name="contact">Contatto</string>
<string name="supports">NIPs Supportati</string>
<string name="admission_fees">Commissioni di Ammissione</string>
<string name="payments_url">Url pagamenti</string>
<string name="limitations">Limitazioni</string>
<string name="countries">Paesi</string>
<string name="languages">Lingue</string>
<string name="tags">Tags</string>
<string name="posting_policy">Politica di pubblicazione</string>
<string name="message_length">Lunghezza del messaggio</string>
<string name="subscriptions">Iscrizioni</string>
<string name="filters">Filtri</string>
<string name="subscription_id_length">Lunghezza ID Iscrizione</string>
<string name="minimum_prefix">Prefisso minimo</string>
<string name="maximum_event_tags">Tags massimi dell\'evento</string>
<string name="content_length">Lunghezza del contenuto</string>
<string name="minimum_pow">PoW Minima</string>
<string name="auth">Auth</string>
<string name="payment">Pagamento</string>
<string name="cashu">Token di Cashu</string>
<string name="cashu_redeem">Riscatta</string>
<string name="no_lightning_address_set">Nessun indirizzo Lightning impostato</string>
<string name="copied_token_to_clipboard">Token copiato negli appunti</string>
<string name="live_stream_live_tag">IN DIRETTA</string>
<string name="live_stream_offline_tag">NON IN LINEA</string>
<string name="live_stream_ended_tag">CONCLUSO</string>
<string name="live_stream_planned_tag">PROGRAMMATO</string>
<string name="live_stream_is_offline">Livestream non è in linea</string>
<string name="live_stream_has_ended">Livestream concluso</string>
<string name="are_you_sure_you_want_to_log_out">La disconnessione elimina tutte le tue informazioni locali. Assicurati di avere le tue chiavi private salvate per evitare di perdere il tuo account. Vuoi continuare?</string>
<string name="followed_tags">Tag Seguiti</string>
<string name="relay_setup">Relè</string>
<string name="discover_live">In diretta</string>
<string name="discover_community">Comunità</string>
<string name="discover_chat">Chat</string>
<string name="community_approved_posts">Post approvati</string>
<string name="groups_no_descriptor">Questo gruppo non ha una descrizione o regole. Parla con il proprietario per aggiungerne una</string>
<string name="community_no_descriptor">Questa comunità non ha una descrizione. Parla con il proprietario per aggiungerne una</string>
<string name="add_sensitive_content_label">Contenuto Sensibile</string>
<string name="add_sensitive_content_description">Aggiunge un avviso di contenuto sensibile prima di mostrare questo contenuto</string>
<string name="settings">Impostazioni</string>
<string name="connectivity_type_always">Sempre</string>
<string name="connectivity_type_wifi_only">Solo Wi-Fi</string>
<string name="connectivity_type_never">Mai</string>
<string name="system">Sistema</string>
<string name="light">Chiaro</string>
<string name="dark">Scuro</string>
<string name="application_preferences">Preferenze dell\'Applicazione</string>
<string name="language">Lingua</string>
<string name="theme">Tema</string>
<string name="automatically_load_images_gifs">Anteprima dell\'immagine</string>
<string name="automatically_play_videos">Riproduzione video</string>
<string name="automatically_show_url_preview">Anteprima URL</string>
<string name="load_image">Carica immagine</string>
<string name="spamming_users">Spammers</string>
<string name="muted_button">Mutato. Clicca per riattivare l\'audio</string>
<string name="mute_button">Audio acceso. Clicca per disattivarlo</string>
<string name="search_button">Cerca registrazioni locali e remote</string>
<string name="nip05_verified">L\'indirizzo Nostr è stato verificato</string>
<string name="nip05_failed">Verifica dell\'indirizzo Nostr non riuscita</string>
<string name="nip05_checking">Controllo dell\'indirizzo Nostr</string>
<string name="select_deselect_all">Seleziona/Deseleziona tutto</string>
<string name="default_relays">Predefinito</string>
<string name="select_a_relay_to_continue">Seleziona un relè per continuare</string>
<string name="zap_forward_title">Inoltra gli Zap a:</string>
<string name="zap_forward_explainer">I client che lo supportano inoltreranno gli Zap all\'indirizzo LN o al profilo dell\'utente di seguito anzichè al tuo</string>
<string name="geohash_title">Esporre la posizione come </string>
<string name="geohash_explainer">Aggiunge un Geohash della tua posizione al post. Il pubblico saprà che ti trovi a 5 km dalla posizione attuale</string>
<string name="add_sensitive_content_explainer">Aggiunge un avviso di contenuto sensibile prima di mostrare il tuo contenuto. Questo è ideale per qualsiasi contenuto NSFW o che alcune persone potrebbero trovare offensivo o disturbante</string>
<string name="new_feature_nip24_might_not_be_available_title">Nuova Funzionalità</string>
<string name="new_feature_nip24_might_not_be_available_description">Attivare questa modalità richiede ad Amethyst di inviare un messaggio NIP-24 (GiftWrapped, Sealed Direct e messaggi di gruppo). NIP-24 è nuovo e la maggior parte dei client non l\'hanno ancora implementato. Assicurarsi che il ricevitore stia utilizzando un client compatibile.</string>
<string name="new_feature_nip24_activate">Attivare</string>
<string name="messages_create_public_chat">Pubblico/a</string>
<string name="messages_new_message">Privato</string>
<string name="messages_new_message_to">A</string>
<string name="messages_new_message_subject">Soggetto</string>
<string name="messages_new_message_subject_caption">Argomento della conversazione</string>
<string name="messages_new_message_to_caption">"\@User1, @User2, @User3"</string>
<string name="messages_group_descriptor">Membri di questo gruppo</string>
<string name="messages_new_subject_message">Spiegazione ai membri</string>
<string name="messages_new_subject_message_placeholder">Cambiare il nome per i nuovi obiettivi.</string>
<string name="language_description">Per l\'interfaccia dell\'app</string>
<string name="theme_description">Tema scuro, chiaro o di sistema</string>
<string name="automatically_load_images_gifs_description">Carica automaticamente immagini e GIF</string>
<string name="automatically_play_videos_description">Riproduce automaticamente video e GIF</string>
<string name="automatically_show_url_preview_description">Mostra anteprime URL</string>
<string name="load_image_description">Quando caricare le immagini</string>
<string name="copy_url_to_clipboard">Copia URL negli appunti</string>
<string name="copy_the_note_id_to_the_clipboard">Copia ID della Nota negli appunti</string>
<string name="created_at">Creato il</string>
<string name="rules">Regole</string>
<string name="status_update">Aggiorna il tuo stato</string>
<string name="lightning_wallets_not_found">Errore analisi messaggio d\'errore</string>
<string name="poll_zap_value_min_max_explainer">I voti sono ponderati con l\'importo dello zap. È possibile impostare una quantità minima per evitare gli spammer e una quantità massima per evitare che un grande zapper predomini il sondaggio. Usa lo stesso importo in entrambi i campi per assicurarti che ogni voto valga lo stesso importo. Lascialo vuoto per accettare qualsiasi importo.</string>
<string name="error_dialog_zap_error">Impossibile inviare lo zap</string>
<string name="error_dialog_talk_to_user">Invia un messaggio all\'utente</string>
<string name="error_dialog_button_ok">OK</string>
<string name="relay_information_document_error_assemble_url">Impossibile raggiungere %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Impossibile raggiungere %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">Impossibile analizzare il risultato da %1$s: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s fallito con il codice %2$s</string>
<string name="active_for">Attivo per: </string>
<string name="active_for_home">Pagina iniziale</string>
<string name="active_for_msg">Messaggi diretti</string>
<string name="active_for_chats">Chat</string>
<string name="active_for_global">Globale</string>
<string name="active_for_search">Cerca</string>
</resources>
+27 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="point_to_the_qr_code">Richt camera op de QR-Code</string>
<string name="point_to_the_qr_code">Richt camera op de QR-code</string>
<string name="show_qr">Toon QR</string>
<string name="profile_image">Profielfoto</string>
<string name="scan_qr">Scan QR</string>
@@ -458,6 +458,7 @@
<string name="messages_group_descriptor">Leden van deze groep</string>
<string name="messages_new_subject_message">Uitleg aan leden</string>
<string name="messages_new_subject_message_placeholder">De naam veranderen voor de nieuwe doelen.</string>
<string name="paste_from_clipboard">Plakken vanaf klembord</string>
<string name="language_description">Voor de interface van de app</string>
<string name="theme_description">Donker-, Licht- of Systeemthema</string>
<string name="automatically_load_images_gifs_description">Automatisch afbeeldingen en GIF\'s laden</string>
@@ -468,10 +469,35 @@
<string name="copy_the_note_id_to_the_clipboard">Note naar klembord kopiëren</string>
<string name="created_at">Gemaakt op</string>
<string name="rules">Regels</string>
<string name="login_with_external_signer">Login met Amber</string>
<string name="status_update">Status bijwerken</string>
<string name="lightning_wallets_not_found">Fout bij het parsen van foutberichten</string>
<string name="poll_zap_value_min_max_explainer">Stemmen worden gewogen door het aantal zap. U kunt een minimumbedrag instellen om spammers te voorkomen en een maximumbedrag om te voorkomen dat een grote zappers de enquête overneemt. Gebruik hetzelfde bedrag in beide velden om ervoor te zorgen dat elke stem hetzelfde wordt gewaardeerd. Laat het leeg om elk bedrag te accepteren.</string>
<string name="error_dialog_zap_error">Kan zap niet verzenden</string>
<string name="error_dialog_talk_to_user">Bericht de gebruiker</string>
<string name="error_dialog_button_ok">Ok</string>
<string name="relay_information_document_error_assemble_url">Mislukt om %1$s te bereiken: %2$s</string>
<string name="relay_information_document_error_reach_server">Mislukt om %1$s te bereiken: %2$s</string>
<string name="relay_information_document_error_parse_result">Mislukt om het resultaat van %1$s te parsen: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s mislukt met code %2$s</string>
<string name="active_for">Actief voor: </string>
<string name="active_for_home">Startpagina</string>
<string name="active_for_msg">DM\'s</string>
<string name="active_for_chats">Chats</string>
<string name="active_for_global">Globaal</string>
<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="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>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Deel zaps met</string>
<string name="forwarding_zaps_to">Stuur zaps door naar</string>
<string name="lightning_wallets_not_found2">Lightning wallets niet gevonden</string>
<string name="paid">Betaald</string>
<string name="wallet_number">Wallet %1$s</string>
<string name="error_opening_external_signer">Fout bij openen ondertekenapp</string>
<string name="sign_request_rejected">Ondertekenverzoek afgewezen</string>
</resources>
+11 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="point_to_the_qr_code">Peka QR-koden</string>
<string name="point_to_the_qr_code">Peka mot QR-koden</string>
<string name="show_qr">Visa QR</string>
<string name="profile_image">Profilbild</string>
<string name="scan_qr">Scanna QR</string>
@@ -472,4 +472,14 @@
<string name="error_dialog_zap_error">Kunde inte skicka zap</string>
<string name="error_dialog_talk_to_user">Meddela användaren</string>
<string name="error_dialog_button_ok">Ok</string>
<string name="relay_information_document_error_assemble_url">Misslyckades att nå %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Misslyckades att nå %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">Misslyckades att parsa resultatet från %1$s: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s misslyckades med kod %2$s</string>
<string name="active_for">Aktiv för: </string>
<string name="active_for_home">Hem</string>
<string name="active_for_msg">Meddelanden</string>
<string name="active_for_chats">Chattar</string>
<string name="active_for_global">Globalt</string>
<string name="active_for_search">Sök</string>
</resources>
@@ -473,4 +473,14 @@
<string name="error_dialog_zap_error">Haiwezekani kutuma zap</string>
<string name="error_dialog_talk_to_user">Tuma ujumbe kwa mtumiaji</string>
<string name="error_dialog_button_ok">Sawa</string>
<string name="relay_information_document_error_assemble_url">Imeshindwa kufikia %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Imeshindwa kufikia %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">Imeshindwa kuchanganua matokeo kutoka %1$s: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s imeshindwa kwa kutumia msimbo %2$s</string>
<string name="active_for">Inatumika kwa: </string>
<string name="active_for_home">Nyumbani</string>
<string name="active_for_msg">DMs</string>
<string name="active_for_chats">Soga</string>
<string name="active_for_global">Ulimwenguni</string>
<string name="active_for_search">Tafuta</string>
</resources>
+104 -18
View File
@@ -4,8 +4,9 @@
<string name="show_qr">显示二维码</string>
<string name="profile_image">头像图片</string>
<string name="scan_qr">扫描二维码</string>
<string name="show_anyway">一直显示</string>
<string name="show_anyway">仍然显示</string>
<string name="post_was_flagged_as_inappropriate_by">帖文被标记为不当</string>
<string name="post_not_found">事件正在加载或无法在你的中继列表中找到</string>
<string name="channel_image">频道图片</string>
<string name="referenced_event_not_found">未找到相关事件</string>
<string name="could_not_decrypt_the_message">无法解密消息</string>
@@ -22,6 +23,7 @@
<string name="copy_note_id">复制笔记ID</string>
<string name="broadcast">广播</string>
<string name="request_deletion">请求删除</string>
<string name="block_report">屏蔽/举报</string>
<string name="block_hide_user"><![CDATA[阻止并隐藏用户]]></string>
<string name="report_spam_scam">举报垃圾邮件/诈骗</string>
<string name="report_impersonation">举报冒充</string>
@@ -31,7 +33,7 @@
<string name="login_with_a_private_key_to_be_able_to_boost_posts">使用私钥登录以便提升帖子</string>
<string name="login_with_a_private_key_to_like_posts">使用私钥登录以便点赞帖子</string>
<string name="no_zap_amount_setup_long_press_to_change">没有设置Zap金额。长按以更改</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">使用私钥登录以便发送Zaps</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">使用私钥登录以便发送打闪</string>
<string name="login_with_a_private_key_to_be_able_to_follow">使用私钥登录以便能够关注</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">使用私钥登录以便能够取消关注</string>
<string name="zaps">打闪</string>
@@ -46,6 +48,7 @@
<string name="in_channel">" 在频道 "</string>
<string name="profile_banner">个人档案横幅</string>
<string name="payment_successful">付款成功</string>
<string name="error_parsing_error_message">错误解析错误消息</string>
<string name="following">" 关注"</string>
<string name="followers">" 粉丝"</string>
<string name="profile">个人档案</string>
@@ -91,7 +94,7 @@
<string name="avatar_url">头像链接</string>
<string name="banner_url">横幅链接</string>
<string name="website_url">网站链接</string>
<string name="ln_address">LN地址</string>
<string name="ln_address">闪电地址</string>
<string name="ln_url_outdated">LN链接(过期)</string>
<string name="image_saved_to_the_gallery">图片已保存到相册</string>
<string name="failed_to_save_the_image">保存图片失败</string>
@@ -190,13 +193,13 @@
<string name="biometric_error">错误</string>
<string name="badge_created_by">"由 %1$s 创建"</string>
<string name="badge_award_image_for">"颁发给 %1$s 的徽章图片"</string>
<string name="new_badge_award_notif">收到了新的徽章奖励</string>
<string name="new_badge_award_notif">收到了新的徽章奖励</string>
<string name="award_granted_to">徽章奖励授予</string>
<string name="copied_note_text_to_clipboard">文本已复制到剪贴板</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">复制作者的 @npub 到剪贴板</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">复制文章ID (@note1) 到剪贴板</string>
<string name="select_text_dialog_top">选择文本</string>
<string name="private_conversation_notification">"&lt;无法解密私人消息&gt;\n\n您被%1$s%2$s之间的私人/加密会话引用。"</string>
<string name="private_conversation_notification">"&lt;无法解密私人消息&gt;\n\n你被 %1$s%2$s 之间的私人/加密会话引用。"</string>
<string name="account_switch_add_account_dialog_title">添加新账户</string>
<string name="drawer_accounts">账户</string>
<string name="account_switch_select_account">选择账户</string>
@@ -215,7 +218,7 @@
<string name="quick_action_unfollow">取关</string>
<string name="quick_action_follow">关注</string>
<string name="quick_action_request_deletion_alert_title">请求删贴</string>
<string name="quick_action_request_deletion_alert_body">Amethyst 将请求的记录从当前连接的中继器中删除。不能保证发布的笔记将被永久从那些中继器或其他存储笔记的中继器中删除。</string>
<string name="quick_action_request_deletion_alert_body">Amethyst 将请求的记录从当前连接的中继器中删除。不能保证发布的笔记将被永久从那些中继器或其他存储笔记的中继器中删除。</string>
<string name="quick_action_block_dialog_btn">阻止</string>
<string name="quick_action_delete_dialog_btn">删除</string>
<string name="quick_action_block">隐藏</string>
@@ -227,12 +230,12 @@
<string name="report_dialog_impersonation">恶意冒充</string>
<string name="report_dialog_nudity">暴露裸露或恶心内容</string>
<string name="report_dialog_illegal">违规行为</string>
<string name="report_dialog_blocking_a_user">阻止一位用户将会隐藏他/她的内容在您的应用中。您的记录仍然是公开可见的,包括被您阻止的用户。阻止的用户将会列在安全过滤器屏幕上。</string>
<string name="report_dialog_blocking_a_user">屏蔽一位用户将会在你的应用中隐藏他的内容。你的笔记仍然是公开可见的,包括对被你屏蔽的用户。屏蔽的用户将会列在安全过滤器屏幕上。</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[阻止并隐藏用户]]></string>
<string name="report_dialog_report_btn">违规报告</string>
<string name="report_dialog_reminder_public">所有举报都将被公开可见</string>
<string name="report_dialog_additional_reason_placeholder">可选择提供关于的举报的额外背景...</string>
<string name="report_dialog_additional_reason_label">其他背景</string>
<string name="report_dialog_additional_reason_placeholder">可选择提供关于的举报的附加背景...</string>
<string name="report_dialog_additional_reason_label">附加背景</string>
<string name="report_dialog_select_reason_label">理由</string>
<string name="report_dialog_select_reason_placeholder">选择一个理由...</string>
<string name="report_dialog_post_report_btn">发布举报</string>
@@ -246,32 +249,44 @@
<string name="remove_from_private_bookmarks">从私人书签中移除</string>
<string name="remove_from_public_bookmarks">从公开书签中移除</string>
<string name="wallet_connect_service">钱包绑定服务</string>
<string name="wallet_connect_service_explainer">使用您的私钥在不离开应用程序的情况下支付 zaps。 任何具有访问 Nostr 私钥的人都可以使用您的钱包余额。要保留您的资金避免损失,如果可能,请使用私人中继器。中继器操作员可以看到您的付款元数据。</string>
<string name="wallet_connect_service_explainer">允许使用 Nostr 私钥在不离开应用程序的情况下支付打闪。请安全保存私钥并在有可能的情况下使用私人中继器</string>
<string name="wallet_connect_service_pubkey">钱包绑定公钥</string>
<string name="wallet_connect_service_relay">钱包绑定中继</string>
<string name="wallet_connect_service_secret">钱包绑定私钥</string>
<string name="wallet_connect_service_show_secret">显示私钥</string>
<string name="wallet_connect_service_secret_placeholder">nsec / 十六进制私钥</string>
<string name="pledge_amount_in_sats">聪数量</string>
<string name="pledge_amount_in_sats">承诺聪金额</string>
<string name="post_poll">发布投票</string>
<string name="poll_heading_required">必填字段:</string>
<string name="poll_zap_recipients">打闪接收</string>
<string name="poll_zap_recipients">打闪接收</string>
<string name="poll_primary_description">投票主要说明…</string>
<string name="poll_option_index">选项 %s</string>
<string name="poll_option_description">投票选项说明</string>
<string name="poll_heading_optional">可选字段:</string>
<string name="poll_zap_value_min">打闪最低金额</string>
<string name="poll_zap_value_max">打闪最高金额</string>
<string name="poll_consensus_threshold">共识</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">后关闭</string>
<string name="poll_closing_time_days"></string>
<string name="poll_is_closed">投票不再接收新投票</string>
<string name="poll_zap_amount">打闪金额</string>
<string name="looking_for_event">"“正在查找事件%1$s”"</string>
<string name="one_vote_per_user_on_atomic_votes">这种投票只允许每个用户一票</string>
<string name="looking_for_event">"正在查找事件 %1$s"</string>
<string name="custom_zaps_add_a_message">添加公开消息</string>
<string name="custom_zaps_add_a_message_private">添加私信</string>
<string name="custom_zaps_add_a_message_nonzap">添加发票消息</string>
<string name="custom_zaps_add_a_message_example">感谢你的所有工作!</string>
<string name="lightning_create_and_add_invoice">创建并添加</string>
<string name="poll_author_no_vote">投票作者不能在自己的投票中投票。</string>
<string name="hash_verification_passed">此内容与发布时相同</string>
<string name="hash_verification_failed">此内容已更改。作者可能没有看到或批准更改</string>
<string name="content_description_add_image">添加图片</string>
<string name="content_description_add_video">添加视频</string>
<string name="content_description_add_document">添加文件</string>
<string name="add_content">添加到消息</string>
<string name="content_description">内容描述</string>
<string name="content_description_example">一艘蓝色船,位于白沙滩的日落点</string>
<string name="content_description_example">一艘蓝色船,白沙滩的日落点</string>
<string name="zap_type">打闪种类</string>
<string name="zap_type_explainer">所有选项的打闪类型</string>
<string name="zap_type_public">公开</string>
@@ -279,16 +294,50 @@
<string name="zap_type_private">私人</string>
<string name="zap_type_private_explainer">发送方和接收方能互相看到并读取消息</string>
<string name="zap_type_anonymous">匿名</string>
<string name="zap_type_anonymous_explainer">接收方和公众不知道谁发送了付款</string>
<string name="zap_type_nonzap">非打闪</string>
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
<string name="file_server">文件服务器</string>
<string name="zap_forward_lnAddress">闪电地址或 @User</string>
<string name="upload_server_imgur">imgur.com - 受信任的</string>
<string name="upload_server_imgur_explainer">Imgur 可以修改文件</string>
<string name="upload_server_nostrimg">nostrimg.com - 受信任的</string>
<string name="upload_server_nostrimg_explainer">NostrImg 可以修改文件</string>
<string name="upload_server_nostrbuild">nostr.build - 受信任的</string>
<string name="upload_server_nostrbuild_explainer">NostrImg 可以修改文件</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - 受信任的</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev 可以修改文件</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - 受信任的</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me 可以修改文件</string>
<string name="upload_server_imgur_nip94">可验证的 Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">检查 Imgur 是否修改了文件。新的 NIP:其他客户端可能看不到它</string>
<string name="upload_server_nostrimg_nip94">可验证的 NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">检查 NostrImg 是否修改了文件。新的 NIP:其他客户端不一定能看到</string>
<string name="upload_server_nostrbuild_nip94">可验证的 Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">检查 Nostr.build 是否修改了文件。新的 NIP:其他客户端不一定能看到</string>
<string name="upload_server_nostrfilesdev_nip94">可验证的 Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">检查 Nostrfiles.dev 是否修改了文件。新的 NIP:其他客户端不一定能看到</string>
<string name="upload_server_nostrcheckme_nip94">可验证的 Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">检查 Nostrcheck.me 是否修改了文件。新的 NIP:其他客户端不一定能看到</string>
<string name="upload_server_relays_nip95">你的中继器 (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">文件由你的中继器托管。新的 NIP:检查它们是否支持</string>
<string name="connect_via_tor_short">Tor/Orbot 设置</string>
<string name="connect_via_tor">通过你的 Orbot 设置连接</string>
<string name="do_you_really_want_to_disable_tor_title">断开与你的 Orbot/Tor 连接?</string>
<string name="do_you_really_want_to_disable_tor_text">你的数据将立即在普通网络上传输</string>
<string name="yes"></string>
<string name="no"></string>
<string name="follow_list_selection">关注列表</string>
<string name="follow_list_kind3follows">所有关注</string>
<string name="follow_list_global">全球</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 通过 Orbot 连线 Tor
\n\n1. 安装 [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. 开启 Orbot
\n3. 在 Orbot 中检查 Socks 端口。默认使用9050
\n4. 如果需要,在 Orbot 中更改端口
\n5. 在此屏幕中配置 Socks 端口
\n6. 按“启用”按钮以使用 Orbot 作为代理
</string>
<string name="orbot_socks_port">Orbot Socks 端口</string>
<string name="invalid_port_number">无效端口</string>
<string name="use_orbot">使用 Orbot</string>
@@ -298,6 +347,8 @@
<string name="app_notification_zaps_channel_name">收到打闪</string>
<string name="app_notification_zaps_channel_description">收到打闪时通知你</string>
<string name="app_notification_zaps_channel_message">%1$s聪</string>
<string name="app_notification_zaps_channel_message_from">来自 %1$s</string>
<string name="app_notification_zaps_channel_message_for">为 %1$s</string>
<string name="reply_notify">通知:</string>
<string name="channel_list_join_conversation">加入对话</string>
<string name="channel_list_user_or_group_id">用户或群组 ID</string>
@@ -306,17 +357,23 @@
<string name="channel_list_join_channel">加入</string>
<string name="today">今天</string>
<string name="content_warning">内容警告</string>
<string name="content_warning_explanation">这个帖子包含敏感内容,一些人可能会觉得有冒犯性或令人不安</string>
<string name="content_warning_hide_all_sensitive_content">始终隐藏敏感内容</string>
<string name="content_warning_show_all_sensitive_content">始终显示敏感内容</string>
<string name="content_warning_see_warnings">始终显示内容警告</string>
<string name="recommended_apps">推荐:</string>
<string name="filter_spam_from_strangers">过滤来自陌生人的垃圾信息</string>
<string name="new_reaction_symbol">新反应符号</string>
<string name="warn_when_posts_have_reports_from_your_follows">当帖子有你的关注的报告时警告</string>
<string name="new_reaction_symbol">新回应符号</string>
<string name="no_reaction_type_setup_long_press_to_change">未选择回应类型。长按可更改</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">为这个帖子添加目标聪金额。支持的客户端可以将此显示为奖励捐赠的进度条</string>
<string name="zapraiser_target_amount_in_sats">目标聪金额</string>
<string name="sats_to_complete">Zapraiser 位于 %1$s。距离目标%2$s聪</string>
<string name="read_from_relay">从中继器读取</string>
<string name="write_to_relay">写入到继电器</string>
<string name="an_error_occurred_trying_to_get_relay_information">尝试从 %1$s 获取中继器信息时出错</string>
<string name="owner">机主</string>
<string name="version">版本</string>
<string name="software">软件</string>
<string name="contact">联络</string>
@@ -327,31 +384,41 @@
<string name="countries">国家</string>
<string name="languages">语言</string>
<string name="tags">标签</string>
<string name="posting_policy">发布政策</string>
<string name="message_length">消息长度</string>
<string name="subscriptions">订阅</string>
<string name="filters">筛选器</string>
<string name="subscription_id_length">订阅 ID 长度</string>
<string name="minimum_prefix">最少前缀</string>
<string name="maximum_event_tags">最多事件标签</string>
<string name="content_length">内容长度</string>
<string name="minimum_pow">最低工作量证明</string>
<string name="auth">认证</string>
<string name="payment">支付</string>
<string name="cashu">Cashu 代币</string>
<string name="cashu_redeem">兑换</string>
<string name="no_lightning_address_set">未设置闪电地址</string>
<string name="copied_token_to_clipboard">已复制令牌至剪贴板</string>
<string name="copied_token_to_clipboard">将代币复制至剪贴板</string>
<string name="live_stream_live_tag">直播</string>
<string name="live_stream_offline_tag">离线</string>
<string name="live_stream_ended_tag">已结束</string>
<string name="live_stream_planned_tag">预定</string>
<string name="live_stream_is_offline">直播处于离线状态</string>
<string name="live_stream_has_ended">直播已结束</string>
<string name="are_you_sure_you_want_to_log_out">登出将删除你的本地信息。 请确保备份你的私钥以避免失去你的帐户。你想要继续吗?</string>
<string name="followed_tags">已关注的标签</string>
<string name="relay_setup">中继器</string>
<string name="discover_live">直播</string>
<string name="discover_community">社区</string>
<string name="discover_chat">聊天</string>
<string name="community_approved_posts">批准帖子</string>
<string name="community_approved_posts">批准帖子</string>
<string name="groups_no_descriptor">此群组没有描述或规则。联系群主来添加</string>
<string name="community_no_descriptor">此社区没有描述或规则。联系群主来添加</string>
<string name="add_sensitive_content_label">敏感内容</string>
<string name="add_sensitive_content_description">在显示此内容之前添加敏感内容警告</string>
<string name="settings">设置</string>
<string name="connectivity_type_always">始终</string>
<string name="connectivity_type_wifi_only">仅限 Wifi</string>
<string name="connectivity_type_wifi_only">仅限 WiFi</string>
<string name="connectivity_type_never">从不</string>
<string name="system">系统</string>
<string name="light">浅色</string>
@@ -363,16 +430,23 @@
<string name="automatically_play_videos">视频播放</string>
<string name="automatically_show_url_preview">URL 预览</string>
<string name="load_image">加载图像</string>
<string name="spamming_users">垃圾邮件</string>
<string name="muted_button">静音。点击取消静音</string>
<string name="mute_button">声音开启。点击静音</string>
<string name="search_button">搜索本地和远程记录</string>
<string name="nip05_verified">Nostr 地址已验证</string>
<string name="nip05_failed">Nostr 地址验证失败</string>
<string name="nip05_checking">正在检查 Nostr 地址</string>
<string name="select_deselect_all">全部选择/取消选择</string>
<string name="default_relays">默认</string>
<string name="select_a_relay_to_continue">选择中继器以继续</string>
<string name="zap_forward_title">将打闪转发到:</string>
<string name="zap_forward_explainer">支持的客户端会将打闪转发到以下的闪电地址或用户个人档案,而不是你的</string>
<string name="geohash_title">将位置显示为 </string>
<string name="geohash_explainer">将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5公里之内(3英里)</string>
<string name="add_sensitive_content_explainer">在显示你的内容之前添加敏感的内容警告。针对任何 NSFW 内容或一些人可能觉得有冒犯性或令人不安的内容。</string>
<string name="new_feature_nip24_might_not_be_available_title">新功能</string>
<string name="new_feature_nip24_might_not_be_available_description">启用此模式需要 Amethyst 发送一条 NIP-24 消息(包装的、密封的私信和群聊消息)。因为 NIP-24 是新的,大多数客户端尚未执行。请确保接收方正在使用兼容的客户端。</string>
<string name="new_feature_nip24_activate">启用</string>
<string name="messages_create_public_chat">公开</string>
<string name="messages_new_message">私人</string>
@@ -382,6 +456,7 @@
<string name="messages_new_message_to_caption">"\@User1、@User2、@User3"</string>
<string name="messages_group_descriptor">此群组成员</string>
<string name="messages_new_subject_message">对成员的解释</string>
<string name="messages_new_subject_message_placeholder">为新目标更改名称。</string>
<string name="language_description">用于应用程序界面</string>
<string name="theme_description">暗色、亮色或系统主题</string>
<string name="automatically_load_images_gifs_description">自动加载图像和 GIF</string>
@@ -394,7 +469,18 @@
<string name="rules">规则</string>
<string name="status_update">更新你的状态</string>
<string name="lightning_wallets_not_found">错误解析错误消息</string>
<string name="poll_zap_value_min_max_explainer">投票按照打闪金额进行加权。 你可以设置最小金额以避免垃圾邮件,也可以设置最大金额以避免大型攻击者攻击民意调查。在两个字段中使用相同的金额以确保每张投票的价值相同。 留空即可接受任何金额。</string>
<string name="error_dialog_zap_error">无法发送打闪</string>
<string name="error_dialog_talk_to_user">向用户发送消息</string>
<string name="error_dialog_button_ok"></string>
<string name="relay_information_document_error_assemble_url">无法连接 %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">无法连接 %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">无法解析来自 %1$s的结果: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s 失败,代码 %2$s</string>
<string name="active_for">活跃于: </string>
<string name="active_for_home">主页</string>
<string name="active_for_msg">私信</string>
<string name="active_for_chats">聊天</string>
<string name="active_for_global">全球</string>
<string name="active_for_search">搜索</string>
</resources>
+174 -2
View File
@@ -6,7 +6,7 @@
<string name="scan_qr">掃描 QR</string>
<string name="show_anyway">一直顯示</string>
<string name="post_was_flagged_as_inappropriate_by">貼文被標記為不當</string>
<string name="post_not_found">未找到貼文</string>
<string name="post_not_found">事件正在加載或無法在你的中繼器列表中找到</string>
<string name="channel_image">頻道圖片</string>
<string name="referenced_event_not_found">未找到相關事件</string>
<string name="could_not_decrypt_the_message">無法解密消息</string>
@@ -22,6 +22,8 @@
<string name="copy_user_pubkey">複製作者@npub</string>
<string name="copy_note_id">複製筆記ID</string>
<string name="broadcast">廣播</string>
<string name="request_deletion">請求刪除</string>
<string name="block_report">屏蔽/舉報</string>
<string name="block_hide_user"><![CDATA[阻止並隱藏用户]]></string>
<string name="report_spam_scam">舉報垃圾郵件/詐騙</string>
<string name="report_impersonation">舉報冒充</string>
@@ -31,7 +33,9 @@
<string name="login_with_a_private_key_to_be_able_to_boost_posts">使用私鑰登錄以便提升貼文</string>
<string name="login_with_a_private_key_to_like_posts">使用私鑰登錄以便點贊貼文</string>
<string name="no_zap_amount_setup_long_press_to_change">沒有設置Zap金額。長按以更改</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">使用私鑰登錄以便發送Zaps</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">使用私鑰登錄以便發送打閃</string>
<string name="login_with_a_private_key_to_be_able_to_follow">使用私鑰登錄以便關注</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">使用私鑰登錄以便取關</string>
<string name="zaps">打閃</string>
<string name="view_count">瀏覽次數</string>
<string name="boost">提升</string>
@@ -44,6 +48,7 @@
<string name="in_channel">" 在頻道 "</string>
<string name="profile_banner">個人檔案橫幅</string>
<string name="payment_successful">付款成功</string>
<string name="error_parsing_error_message">錯誤解析錯誤消息</string>
<string name="following">" 關注"</string>
<string name="followers">" 粉絲"</string>
<string name="profile">個人檔案</string>
@@ -73,6 +78,7 @@
<string name="failed_to_upload_the_image">上傳圖片失敗</string>
<string name="relay_address">中繼器地址</string>
<string name="posts">貼文</string>
<string name="bytes">位元組</string>
<string name="errors">錯誤</string>
<string name="home_feed">主頁</string>
<string name="private_message_feed">私信</string>
@@ -120,6 +126,7 @@
<string name="send_a_direct_message">發送直接消息</string>
<string name="edits_the_user_s_metadata">編輯用户元數據</string>
<string name="follow">關注</string>
<string name="follow_back">回關</string>
<string name="unblock">不隱藏</string>
<string name="copy_user_id">複製用户ID</string>
<string name="unblock_user">隱藏該用户</string>
@@ -193,12 +200,14 @@
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">複製作者的 @npub 到剪貼板</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">複製文章ID (@note1) 到剪貼板</string>
<string name="select_text_dialog_top">選擇文本</string>
<string name="private_conversation_notification">"&lt;無法解密私人消息&gt;\n\n你被 %1$s 和 %2$s 之間的私人/加密對話引用。"</string>
<string name="account_switch_add_account_dialog_title">添加新帳戶</string>
<string name="drawer_accounts">帳戶</string>
<string name="account_switch_select_account">選擇帳戶</string>
<string name="account_switch_add_account_btn">添加新帳戶</string>
<string name="account_switch_active_account">活躍帳戶</string>
<string name="account_switch_has_private_key">有私鑰</string>
<string name="account_switch_pubkey_only">只讀,無私鑰</string>
<string name="back">返回</string>
<string name="quick_action_select">選擇</string>
<string name="quick_action_share_browser_link">分享瀏覽網址</string>
@@ -216,6 +225,18 @@
<string name="quick_action_block">屏蔽</string>
<string name="quick_action_report">舉報</string>
<string name="quick_action_delete_button">刪除</string>
<string name="quick_action_dont_show_again_button">不再顯示</string>
<string name="report_dialog_spam">垃圾信息或詐騙</string>
<string name="report_dialog_profanity">不文明語言或仇恨行為</string>
<string name="report_dialog_impersonation">惡意冒充</string>
<string name="report_dialog_nudity">暴露裸體或噁心內容</string>
<string name="report_dialog_illegal">非法行為</string>
<string name="report_dialog_blocking_a_user">屏蔽一位用戶將會在你的應用中隱藏他的內容。你的筆記仍然是公開可見的,包括對被你屏蔽的用戶。屏蔽的用戶將會被列在安全過濾器屏幕上。</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[阻止並隱藏用户]]></string>
<string name="report_dialog_report_btn">舉報違規</string>
<string name="report_dialog_reminder_public">所有舉報都將被公開可見。</string>
<string name="report_dialog_additional_reason_placeholder">可選擇提供關於你的舉報的附加背景…</string>
<string name="report_dialog_additional_reason_label">附加背景</string>
<string name="report_dialog_select_reason_label">原因</string>
<string name="report_dialog_select_reason_placeholder">選擇原因...</string>
<string name="report_dialog_post_report_btn">發佈舉報</string>
@@ -228,88 +249,239 @@
<string name="add_to_public_bookmarks">添加到公開書籤</string>
<string name="remove_from_private_bookmarks">從私人書籤移除</string>
<string name="remove_from_public_bookmarks">從公開書籤移除</string>
<string name="wallet_connect_service">錢包綁定服務</string>
<string name="wallet_connect_service_explainer">允許使用 Nostr 私鑰在不離開應用程式的情況下支付打閃。請安全保存私鑰並在有可能的情況下使用私人中繼器</string>
<string name="wallet_connect_service_pubkey">錢包綁定公鑰</string>
<string name="wallet_connect_service_relay">錢包綁定中繼</string>
<string name="wallet_connect_service_secret">錢包綁定私鑰</string>
<string name="wallet_connect_service_show_secret">顯示私鑰</string>
<string name="wallet_connect_service_secret_placeholder">nsec / 十六進制私鑰</string>
<string name="pledge_amount_in_sats">承諾聰金額</string>
<string name="post_poll">發佈投票</string>
<string name="poll_heading_required">必填欄位:</string>
<string name="poll_zap_recipients">打閃接收人</string>
<string name="poll_primary_description">投票主要說明…</string>
<string name="poll_option_index">選項 %s</string>
<string name="poll_option_description">投票選擇說明</string>
<string name="poll_heading_optional">可選欄位:</string>
<string name="poll_zap_value_min">打閃最低金額</string>
<string name="poll_zap_value_max">打閃最高金額</string>
<string name="poll_consensus_threshold">共識</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">後關閉</string>
<string name="poll_closing_time_days"></string>
<string name="poll_is_closed">投票不再接收新投票</string>
<string name="poll_zap_amount">打閃金額</string>
<string name="one_vote_per_user_on_atomic_votes">這種投票只允許每個用戶一票</string>
<string name="looking_for_event">"正在查找事件 %1$s"</string>
<string name="custom_zaps_add_a_message">添加公開消息</string>
<string name="custom_zaps_add_a_message_private">添加私人消息</string>
<string name="custom_zaps_add_a_message_nonzap">添加發票消息</string>
<string name="custom_zaps_add_a_message_example">感謝你的所有工作!</string>
<string name="lightning_create_and_add_invoice">創建並添加</string>
<string name="poll_author_no_vote">投票作者不能在自己的投票中投票。</string>
<string name="hash_verification_passed">此內容與發佈時相同</string>
<string name="hash_verification_failed">此內容已更改。作者可能沒有看到或批准更改</string>
<string name="content_description_add_image">添加圖像</string>
<string name="content_description_add_video">添加視頻</string>
<string name="content_description_add_document">添加文件</string>
<string name="add_content">添加到消息</string>
<string name="content_description">內容描述</string>
<string name="content_description_example">一艘藍色的船,在白沙灘上的日落點</string>
<string name="zap_type">打閃種類</string>
<string name="zap_type_explainer">所有選項的打閃類型</string>
<string name="zap_type_public">公開</string>
<string name="zap_type_public_explainer">所有人都能看到交易和消息</string>
<string name="zap_type_private">私人</string>
<string name="zap_type_private_explainer">發送方和接收方能互相看到並讀取消息</string>
<string name="zap_type_anonymous">匿名</string>
<string name="zap_type_anonymous_explainer">接收方和公眾不知道誰發送了付款</string>
<string name="zap_type_nonzap">非打閃</string>
<string name="zap_type_nonzap_explainer">Nostr 上沒有痕跡,僅在閃電上</string>
<string name="file_server">文檔服務器</string>
<string name="zap_forward_lnAddress">LnAddress 或 @User</string>
<string name="upload_server_imgur">imgur.com - 受信任的</string>
<string name="upload_server_imgur_explainer">Imgur 可以修改文件</string>
<string name="upload_server_nostrimg">nostrimg.com - 受信任的</string>
<string name="upload_server_nostrimg_explainer">NostrImg 可以修改文件</string>
<string name="upload_server_nostrbuild">nostr.build - 受信任的</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build 可以修改文件</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - 受信任的</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev 可以修改文件</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - 受信任的</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me 可以修改文件</string>
<string name="upload_server_imgur_nip94">可驗證的 Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">檢查 Imgur 是否修改了文件。新的 NIP:其他客戶端不一定能看到</string>
<string name="upload_server_nostrimg_nip94">可驗證的 NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">檢查 NostrImg 是否修改了文件。新的 NIP:其他客戶端不一定能看到</string>
<string name="upload_server_nostrbuild_nip94">可驗證的 Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">檢查 Nostr.build 是否修改了文件。新的 NIP:其他客戶端不一定能看到</string>
<string name="upload_server_nostrfilesdev_nip94">可驗證的 Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">檢查 Nostrfiles.dev 是否修改了文件。新的 NIP:其他客戶端不一定能看到</string>
<string name="upload_server_nostrcheckme_nip94">可驗證的 Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">檢查 Nostrcheck.me 是否修改了文件。新的 NIP:其他客戶端不一定能看到</string>
<string name="upload_server_relays_nip95">你的中繼器 (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">文檔由你的中繼器託管。新的 NIP:檢查它們是否支持</string>
<string name="connect_via_tor_short">Tor/Orbot 設置</string>
<string name="connect_via_tor">通過你的 Orbot 設置連接</string>
<string name="do_you_really_want_to_disable_tor_title">斷開與你的 Orbot/Tor 連接?</string>
<string name="do_you_really_want_to_disable_tor_text">你的數據將立即在普通網絡上傳輸</string>
<string name="yes"></string>
<string name="no"></string>
<string name="follow_list_selection">關注列表</string>
<string name="follow_list_kind3follows">所有關注</string>
<string name="follow_list_global">全球</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 通過 Orbot 連線 Tor
\n\n1. 安裝 [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. 開啟 Orbot
\n3. 在 Orbot 中檢查 Socks 端口。默認使用9050
\n4. 如果需要,在 Orbot 中更改端口
\n5. 在此屏幕中配置 Socks 端口
\n6. 按“啟用”按鈕以使用 Orbot 為代理
</string>
<string name="orbot_socks_port">Orbot Socks 端口</string>
<string name="invalid_port_number">無效端口</string>
<string name="use_orbot">使用 Orbot</string>
<string name="disconnect_from_your_orbot_setup">斷連 Tor/Orbot</string>
<string name="app_notification_dms_channel_name">私信</string>
<string name="app_notification_dms_channel_description">受到私信時通知你</string>
<string name="app_notification_zaps_channel_name">收到打閃</string>
<string name="app_notification_zaps_channel_description">收到打閃時通知你</string>
<string name="app_notification_zaps_channel_message">%1$s聰</string>
<string name="app_notification_zaps_channel_message_from">來自%1$s</string>
<string name="app_notification_zaps_channel_message_for">為 %1$s</string>
<string name="reply_notify">通知: </string>
<string name="channel_list_join_conversation">加入對話</string>
<string name="channel_list_user_or_group_id">用戶或群組 ID</string>
<string name="channel_list_user_or_group_id_demo">npub,、nevent 或 hex</string>
<string name="channel_list_create_channel">創建</string>
<string name="channel_list_join_channel">加入</string>
<string name="today">今天</string>
<string name="content_warning">內容警告</string>
<string name="content_warning_explanation">這個帖子含有敏感內容,一些人可能會覺得有冒犯性或令人不安</string>
<string name="content_warning_hide_all_sensitive_content">始終隱藏敏感內容</string>
<string name="content_warning_show_all_sensitive_content">始終顯示敏感內容</string>
<string name="content_warning_see_warnings">始終顯示內容警告</string>
<string name="recommended_apps">推薦:</string>
<string name="filter_spam_from_strangers">過濾來自陌生人的垃圾信息</string>
<string name="warn_when_posts_have_reports_from_your_follows">當帖子有你的關注的報告時警告</string>
<string name="new_reaction_symbol">新回應符號</string>
<string name="no_reaction_type_setup_long_press_to_change">未選擇回應類型。長按可更改</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">為這個帖子添加目標聰金額。支持的客戶端可以將此顯示為獎勵捐贈的進度條</string>
<string name="zapraiser_target_amount_in_sats">目標聰金額</string>
<string name="sats_to_complete">Zapraiser 位於 %1$s。距離目標%2$s聰</string>
<string name="read_from_relay">從中繼器讀取</string>
<string name="write_to_relay">寫入到中繼器</string>
<string name="an_error_occurred_trying_to_get_relay_information">嘗試從 %1$s 獲取中繼器信息時出錯</string>
<string name="owner">機主</string>
<string name="version">版本</string>
<string name="software">軟體</string>
<string name="contact">聯絡</string>
<string name="supports">支持的 NIPs</string>
<string name="admission_fees">接納費</string>
<string name="payments_url">付款網址</string>
<string name="limitations">限制</string>
<string name="countries">國家</string>
<string name="languages">語言</string>
<string name="tags">標籤</string>
<string name="posting_policy">發佈政策</string>
<string name="message_length">消息長度</string>
<string name="subscriptions">訂閱</string>
<string name="filters">篩選器</string>
<string name="subscription_id_length">訂閱 ID 長度</string>
<string name="minimum_prefix">最少前綴</string>
<string name="maximum_event_tags">最多事件標籤</string>
<string name="content_length">內容長度</string>
<string name="minimum_pow">最低工作量證明</string>
<string name="auth">認證</string>
<string name="payment">付款</string>
<string name="cashu">Cashu 代幣</string>
<string name="cashu_redeem">兌換</string>
<string name="no_lightning_address_set">未設定閃電地址</string>
<string name="copied_token_to_clipboard">已將代幣複製到剪貼簿</string>
<string name="live_stream_live_tag">直播</string>
<string name="live_stream_offline_tag">離線</string>
<string name="live_stream_ended_tag">已結束</string>
<string name="live_stream_planned_tag">預定</string>
<string name="live_stream_is_offline">直播處於離線狀態</string>
<string name="live_stream_has_ended">直播已結束</string>
<string name="are_you_sure_you_want_to_log_out">登出將刪除你的本地信息。請確保備份你的私鑰以避免失去你的帳戶。你想要繼續嗎?</string>
<string name="followed_tags">以關注的標籤</string>
<string name="relay_setup">中繼器</string>
<string name="discover_live">直播</string>
<string name="discover_community">社群</string>
<string name="discover_chat">聊天</string>
<string name="community_approved_posts">批准的帖子</string>
<string name="groups_no_descriptor">此群組沒有描述或規則。聯繫群主來添加</string>
<string name="community_no_descriptor">此社區沒有描述或規則。聯繫群主來添加</string>
<string name="add_sensitive_content_label">敏感內容</string>
<string name="add_sensitive_content_description">在顯示此內容之前添加敏感內容警告</string>
<string name="settings">設置</string>
<string name="connectivity_type_always">始終</string>
<string name="connectivity_type_wifi_only">僅限 WiFi</string>
<string name="connectivity_type_never">永不</string>
<string name="system">系統</string>
<string name="light">淺色</string>
<string name="dark">深色</string>
<string name="application_preferences">應用程式首選項</string>
<string name="language">語言</string>
<string name="theme">主題</string>
<string name="automatically_load_images_gifs">圖像預覽</string>
<string name="automatically_play_videos">視頻播放</string>
<string name="automatically_show_url_preview">鏈接預覽</string>
<string name="load_image">加載圖像</string>
<string name="spamming_users">垃圾郵件</string>
<string name="muted_button">靜音。點擊取消靜音</string>
<string name="mute_button">聲音開啟。點擊靜音</string>
<string name="search_button">搜索本地和遠程記錄</string>
<string name="nip05_verified">Nostr 地址已驗證</string>
<string name="nip05_failed">Nostr 地址驗證失敗</string>
<string name="nip05_checking">正在檢查 Nostr 地址</string>
<string name="select_deselect_all">全部選擇/取消選擇</string>
<string name="default_relays">默認</string>
<string name="select_a_relay_to_continue">選擇中繼器以繼續</string>
<string name="zap_forward_title">將打閃轉發到:</string>
<string name="zap_forward_explainer">支持的客戶端會將打閃轉發到以下的閃電地址或用戶個人檔案,而不是你的</string>
<string name="geohash_title">將位置顯示為 </string>
<string name="geohash_explainer">將你所在位置的地理位置添加到帖子。公眾會知道你在當前位置的5公里之內(3英里)</string>
<string name="add_sensitive_content_explainer">在顯示你的內容之前添加敏感內容警告。針對任何 NSFW 內容或一些人可能覺得有冒犯性或令人不安的內容。</string>
<string name="new_feature_nip24_might_not_be_available_title">新功能</string>
<string name="new_feature_nip24_might_not_be_available_description">啟用此模式需要 Amethyst 發送一條 NIP-24 消息(包裝的、密封的私信和群聊消息)。因為 NIP-24 是新的,大多數客戶端尚未執行。請確保接收方正在使用兼容的客戶端。</string>
<string name="new_feature_nip24_activate">啟用</string>
<string name="messages_create_public_chat">公開</string>
<string name="messages_new_message">私人</string>
<string name="messages_new_message_to"></string>
<string name="messages_new_message_subject">主題</string>
<string name="messages_new_message_subject_caption">對話主題</string>
<string name="messages_new_message_to_caption">"\@User1、@User2、@User3"</string>
<string name="messages_group_descriptor">此群組成員</string>
<string name="messages_new_subject_message">對成員的解釋</string>
<string name="messages_new_subject_message_placeholder">為新目標更改名稱。</string>
<string name="language_description">用於應用程式界面</string>
<string name="theme_description">暗色、亮色或系統主題</string>
<string name="automatically_load_images_gifs_description">自動加載圖像和 GIF</string>
<string name="automatically_play_videos_description">自動播放視頻和 GIF</string>
<string name="automatically_show_url_preview_description">顯示鏈接預覽</string>
<string name="load_image_description">何時加載圖像</string>
<string name="copy_url_to_clipboard">將 URL 複製到剪貼簿</string>
<string name="copy_the_note_id_to_the_clipboard">將筆記 ID 複製到剪貼簿</string>
<string name="created_at">創建於</string>
<string name="rules">規則</string>
<string name="status_update">更新你的狀態</string>
<string name="lightning_wallets_not_found">錯誤解析錯誤消息</string>
<string name="poll_zap_value_min_max_explainer">投票按照打閃金額進行加權。你可以設置最小金額以避免垃圾郵件,也可以設置最大金額以避免大型攻擊者攻擊民意調查。在兩個字段中使用相同的金額則確保每張投票的價值相同。留空即可接受任何金額。</string>
<string name="error_dialog_zap_error">無法發送打閃</string>
<string name="error_dialog_talk_to_user">向用戶發送消息</string>
<string name="error_dialog_button_ok">確定</string>
<string name="relay_information_document_error_assemble_url">無法連接 %1$s%2$s</string>
<string name="relay_information_document_error_reach_server">無法連接 %1$s%2$s</string>
<string name="relay_information_document_error_parse_result">無法解析來自 %1$s 的結果:%2$s</string>
<string name="relay_information_document_error_http_status">%1$s 失敗了,代碼 %2$s</string>
<string name="active_for">活躍於: </string>
<string name="active_for_home">首頁</string>
<string name="active_for_msg">私信</string>
<string name="active_for_chats">聊天</string>
<string name="active_for_global">全球</string>
<string name="active_for_search">搜索</string>
</resources>
+20
View File
@@ -537,6 +537,8 @@
<string name="messages_new_subject_message">Explanation to members</string>
<string name="messages_new_subject_message_placeholder">Changing the name for the new goals.</string>
<string name="paste_from_clipboard">Paste from clipboard</string>
<string name="language_description">For the App\'s Interface</string>
<string name="theme_description">Dark, Light or System theme</string>
<string name="automatically_load_images_gifs_description">Automatically load images and GIFs</string>
@@ -550,6 +552,8 @@
<string name="created_at">Created at</string>
<string name="rules">Rules</string>
<string name="login_with_external_signer">Login with Amber</string>
<string name="status_update">Update your status</string>
<string name="lightning_wallets_not_found">Error parsing error message</string>
@@ -570,4 +574,20 @@
<string name="active_for_chats">Chats</string>
<string name="active_for_global">Global</string>
<string name="active_for_search">Search</string>
<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="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>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Splitting zaps with</string>
<string name="forwarding_zaps_to">Forwarding zaps to</string>
<string name="lightning_wallets_not_found2">Lightning wallets not found</string>
<string name="paid">Paid</string>
<string name="wallet_number">Wallet %1$s</string>
<string name="error_opening_external_signer">Error opening signer app</string>
<string name="sign_request_rejected">Sign request rejected</string>
</resources>
@@ -6,8 +6,12 @@ import com.vitorpamplona.amethyst.AccountInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.tasks.await
class PushNotificationUtils {
object PushNotificationUtils {
var hasInit: Boolean = false
suspend fun init(accounts: List<AccountInfo>) = with(Dispatchers.IO) {
if (hasInit) {
return@with
}
// get user notification token provided by firebase
try {
RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await())
@@ -14,7 +14,6 @@ import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
@@ -42,14 +41,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Locale
@Immutable
data class TranslationConfig(
val result: String?,
val sourceLang: String?,
val targetLang: String?,
val showOriginal: Boolean
)
@Composable
fun TranslatableRichTextViewer(
content: String,
@@ -312,18 +303,17 @@ private fun TranslationMessage(
@Composable
fun TranslateAndWatchLanguageChanges(content: String, accountViewModel: AccountViewModel, onTranslated: (TranslationConfig) -> Unit) {
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
LaunchedEffect(accountState) {
launch(Dispatchers.IO) {
accountViewModel.runOnIO {
LanguageTranslatorService.autoTranslate(
content,
account.dontTranslateFrom,
account.translateTo
accountViewModel.account.dontTranslateFrom,
accountViewModel.account.translateTo
).addOnCompleteListener { task ->
if (task.isSuccessful && !content.equals(task.result.result, true)) {
if (task.result.sourceLang != null && task.result.targetLang != null) {
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
val preference = accountViewModel.account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
val newConfig = TranslationConfig(
result = task.result.result,
sourceLang = task.result.sourceLang,
@@ -331,9 +321,7 @@ fun TranslateAndWatchLanguageChanges(content: String, accountViewModel: AccountV
showOriginal = preference == task.result.sourceLang
)
// withContext(Dispatchers.Main) {
onTranslated(newConfig)
// }
}
}
}
@@ -1,5 +1,8 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.quartz.encoders.Nip19
import org.junit.Assert.assertEquals
@@ -11,9 +14,23 @@ import org.junit.Test
* See [testing documentation](http://d.android.com/tools/testing).
*/
class NewMessageTaggerKeyParseTest {
val dao: Dao = object : Dao {
override suspend fun getOrCreateUser(hex: String): User {
return User(hex)
}
override suspend fun getOrCreateNote(hex: String): Note {
return Note(hex)
}
override suspend fun checkGetOrCreateAddressableNote(hex: String): Note? {
return Note(hex)
}
}
@Test
fun keyParseTestNote() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn")
assertEquals(Nip19.Type.NOTE, result?.key?.type)
assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex)
assertEquals("", result?.restOfWord)
@@ -21,7 +38,7 @@ class NewMessageTaggerKeyParseTest {
@Test
fun keyParseTestPub() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
assertEquals(Nip19.Type.USER, result?.key?.type)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex)
assertEquals("", result?.restOfWord)
@@ -29,7 +46,7 @@ class NewMessageTaggerKeyParseTest {
@Test
fun keyParseTestNoteWithExtraChars() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,")
assertEquals(Nip19.Type.NOTE, result?.key?.type)
assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex)
assertEquals(",", result?.restOfWord)
@@ -37,7 +54,7 @@ class NewMessageTaggerKeyParseTest {
@Test
fun keyParseTestPubWithExtraChars() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,")
assertEquals(Nip19.Type.USER, result?.key?.type)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex)
assertEquals(",", result?.restOfWord)
@@ -45,7 +62,7 @@ class NewMessageTaggerKeyParseTest {
@Test
fun keyParseTestNoteWithExtraCharsAndAt() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,")
assertEquals(Nip19.Type.NOTE, result?.key?.type)
assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex)
assertEquals(",", result?.restOfWord)
@@ -53,7 +70,7 @@ class NewMessageTaggerKeyParseTest {
@Test
fun keyParseTestPubWithExtraCharsAndAt() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,")
assertEquals(Nip19.Type.USER, result?.key?.type)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex)
assertEquals(",", result?.restOfWord)
@@ -61,7 +78,7 @@ class NewMessageTaggerKeyParseTest {
@Test
fun keyParseTestNoteWithExtraCharsAndNostrPrefix() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,")
assertEquals(Nip19.Type.NOTE, result?.key?.type)
assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex)
assertEquals(",", result?.restOfWord)
@@ -69,7 +86,7 @@ class NewMessageTaggerKeyParseTest {
@Test
fun keyParseTestPubWithExtraCharsAndNostrPrefix() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,")
assertEquals(Nip19.Type.USER, result?.key?.type)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex)
assertEquals(",", result?.restOfWord)
@@ -77,7 +94,7 @@ class NewMessageTaggerKeyParseTest {
@Test
fun keyParseTestUppercaseNoteWithExtraCharsAndNostrPrefix() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,")
assertEquals(Nip19.Type.NOTE, result?.key?.type)
assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex)
assertEquals(",", result?.restOfWord)
@@ -85,7 +102,7 @@ class NewMessageTaggerKeyParseTest {
@Test
fun keyParseTestUppercasePubWithExtraCharsAndNostrPrefix() {
val result = NewMessageTagger(message = "").parseDirtyWordForKey("nOstr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,")
val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("nOstr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,")
assertEquals(Nip19.Type.USER, result?.key?.type)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex)
assertEquals(",", result?.restOfWord)
@@ -0,0 +1,89 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.ui.components.Split
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SplitterTest {
@Test
fun testSplit() {
val mySplit = Split<String>()
val vitor = mySplit.addItem("Vitor")
assertEquals(1f, mySplit.items[vitor].percentage, 0.01f)
assertTrue(mySplit.isEqualSplit())
val pablo = mySplit.addItem("Pablo")
assertEquals(0.5f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f)
assertTrue(mySplit.isEqualSplit())
val gigi = mySplit.addItem("Gigi")
assertEquals(0.33f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(0.33f, mySplit.items[gigi].percentage, 0.01f)
assertTrue(mySplit.isEqualSplit())
mySplit.updatePercentage(vitor, 0.5f)
assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(0.16f, mySplit.items[gigi].percentage, 0.01f)
assertFalse(mySplit.isEqualSplit())
mySplit.updatePercentage(vitor, 0.95f)
assertEquals(0.95f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(0.0f, mySplit.items[gigi].percentage, 0.01f)
assertFalse(mySplit.isEqualSplit())
mySplit.updatePercentage(vitor, 0.15f)
assertEquals(0.15f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(0.80f, mySplit.items[gigi].percentage, 0.01f)
assertFalse(mySplit.isEqualSplit())
mySplit.updatePercentage(pablo, 0.95f)
assertEquals(0.05f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.95f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(0.00f, mySplit.items[gigi].percentage, 0.01f)
mySplit.updatePercentage(gigi, 1f)
assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f)
mySplit.updatePercentage(vitor, 0.5f)
assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f)
mySplit.updatePercentage(pablo, 0.3f)
assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.30f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(0.20f, mySplit.items[gigi].percentage, 0.01f)
mySplit.updatePercentage(gigi, 1f)
assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f)
mySplit.updatePercentage(gigi, 0.5f)
assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f)
assertEquals(0.50f, mySplit.items[pablo].percentage, 0.01f)
assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f)
}
}
@@ -34,7 +34,7 @@ class GiftWrapBenchmark {
val events = NIP24Factory().createMsgNIP24(
message,
listOf(receiver.pubKey.toHexKey()),
sender.privKey!!
sender
)
Assert.assertEquals(expectedLength, events.map { it.toJson() }.joinToString("").length)
@@ -63,7 +63,7 @@ class GiftWrapBenchmark {
val giftWrap = NIP24Factory().createMsgNIP24(
message,
listOf(receiver.pubKey.toHexKey()),
sender.privKey!!
sender
).first()
val keyToUse = if (giftWrap.recipientPubKey() == sender.pubKey.toHexKey()) sender.privKey!! else receiver.privKey!!
+6 -6
View File
@@ -3,19 +3,19 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
buildscript {
ext {
fragment_version = "1.6.1"
lifecycle_version = '2.6.1'
compose_ui_version = '1.5.0'
nav_version = "2.7.1"
lifecycle_version = '2.6.2'
compose_ui_version = '1.5.1'
nav_version = '2.7.2'
room_version = "2.4.3"
accompanist_version = '0.30.1'
coil_version = '2.4.0'
vico_version = '1.11.1'
vico_version = '1.11.3'
exoplayer_version = '1.1.1'
media3_version = '1.1.1'
core_ktx_version = '1.10.1'
core_ktx_version = '1.12.0'
}
dependencies {
classpath 'com.google.gms:google-services:4.3.15'
classpath 'com.google.gms:google-services:4.4.0'
}
}
@@ -69,14 +69,14 @@ object Bech32 {
values.forEach { v ->
val b = chk shr 25
chk = ((chk and 0x1ffffff) shl 5) xor v.toInt()
for (i in 0..5) {
for (i in 0..4) {
if (((b shr i) and 1) != 0) chk = chk xor GEN[i]
}
}
values1.forEach { v ->
val b = chk shr 25
chk = ((chk and 0x1ffffff) shl 5) xor v.toInt()
for (i in 0..5) {
for (i in 0..4) {
if (((b shr i) and 1) != 0) chk = chk xor GEN[i]
}
}
@@ -129,7 +129,7 @@ object Bech32 {
}
}
val hrp = bech32.take(pos)
val hrp = bech32.take(pos).lowercase() // strings must be lower case
require(hrp.length in 1..83) { "hrp must contain 1 to 83 characters" }
val data = Array(bech32.length - pos - 1) {
@@ -12,18 +12,19 @@ fun HexKey.hexToByteArray(): ByteArray {
}
object HexValidator {
private fun isHex2(c: Char): Boolean {
private fun isHexChar(c: Char): Boolean {
return when (c) {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F' -> true
else -> false
}
}
fun isHex(hex: String?): Boolean {
if (hex == null) return false
if (hex.length % 2 != 0) return false // must be even
var isHex = true
for (c in hex.toCharArray()) {
if (!isHex2(c)) {
if (!isHexChar(c)) {
isHex = false
break
}
@@ -0,0 +1,35 @@
package com.vitorpamplona.quartz.encoders
import android.util.Log
import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
class Lud06 {
fun toLud16(str: String): String? {
return try {
val url = toLnUrlp(str)
val matcher = lnurlpPattern.matcher(url)
matcher.find()
val domain = matcher.group(2)
val username = matcher.group(3)
"$username@$domain"
} catch (t: Throwable) {
t.printStackTrace()
Log.w("Lud06ToLud16","Fail to convert LUD06 to LUD16",t)
null
}
}
fun toLnUrlp(str: String): String? {
return try {
String(Bech32.decodeBytes(str, false).second)
} catch (t: Throwable) {
t.printStackTrace()
Log.w("Lud06ToLud16","Fail to convert LUD06 to LUD16",t)
null
}
}
}
@@ -15,16 +15,28 @@ class AppDefinitionEvent(
content: String,
sig: HexKey
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun appMetaData() = try {
mapper.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
null
}
@Transient
private var cachedMetadata: UserMetadata? = null
fun appMetaData() =
if (cachedMetadata != null) {
cachedMetadata
} else {
try {
val newMetadata = mapper.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
cachedMetadata = newMetadata
newMetadata
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
null
}
}
fun supportedKinds() = tags.filter { it.size > 1 && it[0] == "k" }.mapNotNull {
runCatching { it[1].toInt() }.getOrNull()

Some files were not shown because too many files have changed in this diff Show More