Compare commits

...

162 Commits

Author SHA1 Message Date
Vitor Pamplona
18e81de26d v0.77.6 2023-09-21 10:38:02 -04:00
Vitor Pamplona
27f37fc158 Removes the flickering on chat screens when the bottom bar disappears. 2023-09-21 10:32:06 -04:00
Vitor Pamplona
6638ae1d7b no need for coroutines on nav anymore 2023-09-21 09:20:11 -04:00
Vitor Pamplona
42094c2a70 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-21 09:09:35 -04:00
Vitor Pamplona
932abdd412 Fixes video keep playing bug when pressing the bottom bar twice. 2023-09-21 09:09:24 -04:00
Vitor Pamplona
b4bb6f2b15 Merge pull request #608 from davotoula/main
"show more" text folding enhancement
2023-09-21 08:19:02 -04:00
David Kaspar
4c7b523de1 Merge remote-tracking branch 'origin/show_more_enhancement' into show_more_enhancement 2023-09-21 08:47:40 +02:00
David Kaspar
59608e9acc Combine new line limit with previous character checking (use the smallest) 2023-09-21 08:47:09 +02:00
David Kaspar
77afedb2d0 Introduced line limit in addition to existing character limit for "show more" folding of text 2023-09-21 08:47:09 +02:00
David Kaspar
ccb2c626a7 Combine new line limit with previous character checking (use the smallest) 2023-09-21 08:27:16 +02:00
David Kaspar
9b6eec5fdd Introduced line limit in addition to existing character limit for "show more" folding of text 2023-09-21 08:11:25 +02:00
Vitor Pamplona
02786fb7d3 v0.77.5 2023-09-20 21:22:21 -04:00
Vitor Pamplona
384594ba5c - Inverts the scroll to hide for channels and chats
- Awaits half of the bar to change visibility
2023-09-20 21:21:13 -04:00
Vitor Pamplona
dab02f2cbd v0.77.4 2023-09-20 19:05:00 -04:00
Vitor Pamplona
978f13fbf3 Activates sliding effect for all screens 2023-09-20 18:59:49 -04:00
Vitor Pamplona
831386a656 Using Slide in and out instead of crossfade for the top and bottom bars. 2023-09-20 18:57:11 -04:00
Vitor Pamplona
8b7406e25a Merge pull request #607 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-20 18:41:11 -04:00
Crowdin Bot
9e7fed6646 New Crowdin translations by GitHub Action 2023-09-20 22:19:01 +00:00
Vitor Pamplona
89d88326e8 Merge pull request #606 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-20 18:18:08 -04:00
Vitor Pamplona
f18dd02161 Merge pull request #605 from greenart7c3/main
immersive scrolling on home feed
2023-09-20 18:17:56 -04:00
Crowdin Bot
e00db19485 New Crowdin translations by GitHub Action 2023-09-20 22:14:39 +00:00
Vitor Pamplona
a706b71d7c Moves reaction calls to the viewModelScope 2023-09-20 18:12:41 -04:00
greenart7c3
a83b26cc6f immersive scrolling on home feed 2023-09-20 18:18:56 -03:00
Vitor Pamplona
dc73843447 Fixes OK string 2023-09-20 16:43:57 -04:00
Vitor Pamplona
3e6aa2b943 Protects against null contact lists 2023-09-20 16:32:38 -04:00
Vitor Pamplona
909d3f4fbe Migrates alt descriptions from .content to alt tag. 2023-09-20 16:18:21 -04:00
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
c84e835ce5 Merge pull request #543 from greenart7c3/amber
Add Amber support
2023-09-18 11:16:55 -04:00
Vitor Pamplona
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 Pamplona
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 Pamplona
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
greenart7c3
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
greenart7c3
6876a7f0d2 zaps in background 2023-09-15 13:53:05 -03:00
greenart7c3
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
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
greenart7c3
a2fcb3539e Merge branch 'main' into amber 2023-09-13 13:15:45 -03: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
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
greenart7c3
7ff6f16df0 Merge branch 'main' into amber 2023-09-11 07:20:03 -03:00
greenart7c3
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
greenart7c3
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
greenart7c3
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
greenart7c3
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
greenart7c3
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
greenart7c3
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
greenart7c3
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
greenart7c3
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
greenart7c3
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
117 changed files with 4735 additions and 1413 deletions

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>

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 295
versionName "0.76.1"
versionCode 303
versionName "0.77.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -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)

View File

@@ -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>) {
}
}

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 -->

View File

@@ -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,22 @@ 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 {
Event.fromJson(it) as ContactListEvent?
println("Decoding Contact List: " + it)
if (it != null) {
Event.fromJson(it) as ContactListEvent?
} else {
null
}
}
} catch (e: Throwable) {
e.printStackTrace()
Log.w("LocalPreferences", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", e)
null
}
@@ -353,6 +368,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 +379,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 +394,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 +449,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)
}
}

View File

@@ -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

View File

@@ -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.*
@@ -206,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()}")

View File

@@ -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

View File

@@ -319,7 +319,7 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<HexKey> {
fun cachedFollowingTagSet(): Set<String> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}

View File

@@ -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
)
}
}

View File

@@ -16,16 +16,16 @@ class FileHeader(
val size: Int,
val dim: String?,
val blurHash: String?,
val description: String? = null,
val alt: String? = null,
val sensitiveContent: Boolean = false
) {
companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, sensitiveContent, onReady, onError)
prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError)
} else {
onError()
}
@@ -39,7 +39,7 @@ class FileHeader(
data: ByteArray,
fileUrl: String,
mimeType: String?,
description: String?,
alt: String?,
sensitiveContent: Boolean,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
@@ -79,7 +79,7 @@ class FileHeader(
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description, sensitiveContent))
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()

View File

@@ -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,

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -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 -> {

View File

@@ -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() }} ]

View File

@@ -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)

View File

@@ -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()
}
}
}
}

View File

@@ -26,7 +26,7 @@ open class NewMediaModel : ViewModel() {
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var description by mutableStateOf("")
var alt by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
// Images and Videos
@@ -79,7 +79,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList)
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
}
?: run {
viewModelScope.launch {
@@ -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,
alt,
sensitiveContent,
relayList = relayList
)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
)
)
}
}
},
onError = {
@@ -131,7 +139,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = null
uploadingPercentage.value = 0.0f
description = ""
alt = ""
selectedServer = account?.defaultFileServer
}
@@ -139,7 +147,7 @@ open class NewMediaModel : ViewModel() {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
@@ -158,7 +166,7 @@ open class NewMediaModel : ViewModel() {
imageData,
imageUrl,
mimeType,
description,
alt,
sensitiveContent,
onReady = {
uploadingPercentage.value = 0.90f
@@ -192,7 +200,7 @@ open class NewMediaModel : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing"
@@ -201,7 +209,7 @@ open class NewMediaModel : ViewModel() {
bytes,
"",
mimeType,
description,
alt,
sensitiveContent,
onReady = {
uploadingDescription.value = "Signing"

View File

@@ -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
},
@@ -288,8 +287,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = postViewModel.description,
onValueChange = { postViewModel.description = it },
value = postViewModel.alt,
onValueChange = { postViewModel.alt = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),

View File

@@ -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? {
@@ -155,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?
}

View File

@@ -93,6 +93,7 @@ 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
@@ -142,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 ->
@@ -211,10 +212,8 @@ fun NewPostView(
}
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost(relayList = relayList)
onClose()
}
postViewModel.sendPost(relayList = relayList)
onClose()
},
isActive = postViewModel.canPost()
)
@@ -339,8 +338,8 @@ fun NewPostView(
ImageVideoDescription(
url,
account.defaultFileServer,
onAdd = { description, server, sensitiveContent ->
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
onAdd = { alt, server, sensitiveContent ->
postViewModel.upload(url, alt, sensitiveContent, server, context, relayList)
account.changeDefaultFileServer(server)
},
onCancel = {
@@ -1320,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("") }
@@ -1435,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
},

View File

@@ -23,6 +23,7 @@ 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
@@ -45,6 +46,7 @@ enum class UserSuggestionAnchor {
@Stable
open class NewPostViewModel() : ViewModel() {
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var requiresNIP24: Boolean = false
@@ -115,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) {
@@ -147,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
@@ -160,14 +162,26 @@ open class NewPostViewModel() : ViewModel() {
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
@@ -285,7 +299,7 @@ open class NewPostViewModel() : ViewModel() {
cancel()
}
fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
fun upload(galleryUri: Uri, alt: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
isUploadingImage = true
contentToAddUrl = null
@@ -300,7 +314,7 @@ open class NewPostViewModel() : ViewModel() {
onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList)
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
}
} else {
ImageUploader.uploadImage(
@@ -311,7 +325,7 @@ open class NewPostViewModel() : ViewModel() {
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description, sensitiveContent)
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent)
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
@@ -523,13 +537,13 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
FileHeader.prepare(
imageUrl,
mimeType,
description,
alt,
sensitiveContent,
onReady = {
val note = account?.sendHeader(it, relayList = relayList)
@@ -554,13 +568,13 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
description,
alt,
sensitiveContent,
onReady = {
val nip95 = account?.createNip95(bytes, headerInfo = it)

View File

@@ -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),

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.events.ImmutableListOfLists
const val SHORT_TEXT_LENGTH = 350
const val SHORTEN_AFTER_LINES = 10
@Composable
fun ExpandableRichTextViewer(
@@ -45,11 +46,25 @@ fun ExpandableRichTextViewer(
var showFullText by remember { mutableStateOf(false) }
val whereToCut = remember(content) {
// Cuts the text in the first space after 350
// Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
// or after SHORTEN_AFTER_LINES lines
val numberOfLines = content.count { it == '\n' }
var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
if (numberOfLines > SHORTEN_AFTER_LINES) {
val shortContent = content.lines().take(SHORTEN_AFTER_LINES)
charactersInLines = 0
for (line in shortContent) {
// +1 because new line character is omitted from .lines
charactersInLines += (line.length + 1)
}
}
minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines)
}
val text by remember(content) {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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
@@ -281,12 +282,14 @@ fun GetVideoController(
UUID.randomUUID().toString()
}
val scope = rememberCoroutineScope()
// Prepares a VideoPlayer from the foreground service.
LaunchedEffect(key1 = videoUri) {
DisposableEffect(key1 = videoUri) {
// If it is not null, the user might have come back from a playing video, like clicking on
// the notification of the video player.
if (controller.value == null) {
launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
PlaybackClientController.prepareController(
uid,
videoUri,
@@ -350,12 +353,20 @@ fun GetVideoController(
}
}
}
onDispose {
if (!keepPlaying.value) {
// Stops and releases the media.
controller.value?.stop()
controller.value?.release()
controller.value = null
}
}
}
// User pauses and resumes the app. What to do with videos?
val scope = rememberCoroutineScope()
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(key1 = videoUri) {
DisposableEffect(key1 = lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
// if the controller is null, restarts the controller with a new one
@@ -363,7 +374,7 @@ fun GetVideoController(
if (controller.value == null) {
scope.launch(Dispatchers.IO) {
PlaybackClientController.prepareController(
UUID.randomUUID().toString(),
uid,
videoUri,
nostrUriCallback,
context
@@ -407,13 +418,6 @@ fun GetVideoController(
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
if (!keepPlaying.value) {
// Stops and releases the media.
controller.value?.stop()
controller.value?.release()
controller.value = null
}
}
}
@@ -612,7 +616,7 @@ fun Waveform(
DrawWaveform(waveform, waveformProgress, modifier)
val restartFlow = remember {
mutableStateOf(0)
mutableIntStateOf(0)
}
// Keeps the screen on while playing and viewing videos.

View File

@@ -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 -> {

View File

@@ -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()
}
}
}

View File

@@ -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()

View File

@@ -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)
}
}
}

View File

@@ -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("")

View File

@@ -150,6 +150,14 @@ sealed class Route(
route = "Settings",
icon = R.drawable.ic_settings
)
companion object {
val InvertedLayouts = setOf(
Channel.route,
Room.route,
RoomByAuthor.route
)
}
}
// **

View File

@@ -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()
)

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)
}
}
// }
}
}

View File

@@ -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(

View File

@@ -54,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
@@ -84,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
@@ -135,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
@@ -152,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
@@ -179,12 +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
@@ -631,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
@@ -673,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
}
}
}
}
@@ -1042,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(
@@ -1076,6 +1078,8 @@ private fun NoteBody(
fun DisplayZapSplits(noteEvent: EventInterface, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val list = remember(noteEvent) { noteEvent.zapSplitSetup() }
if (list.isEmpty()) return
Row(verticalAlignment = CenterVertically) {
Box(
Modifier
@@ -1340,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,
@@ -1379,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,
@@ -1554,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)
@@ -1621,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
@@ -1645,7 +1647,7 @@ private fun RenderPrivateMessage(
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
content = eventContent,
content = eventContent!!,
canPreview = canPreview && !makeItShort,
modifier = modifier,
tags = tags,
@@ -1655,7 +1657,7 @@ private fun RenderPrivateMessage(
)
}
DisplayUncitedHashtags(hashtags, eventContent, nav)
DisplayUncitedHashtags(hashtags, eventContent!!, nav)
}
}
} else {
@@ -1669,7 +1671,7 @@ private fun RenderPrivateMessage(
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
ImmutableListOfLists(),
EmptyTagList,
backgroundColor,
accountViewModel,
nav
@@ -1814,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)
}
@@ -1845,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
}
}
@@ -1896,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
}
}
@@ -2013,7 +2008,7 @@ fun RenderPostApproval(
Column(Modifier.fillMaxWidth()) {
noteEvent.communities().forEach {
LoadAddressableNote(it) {
LoadAddressableNote(it, accountViewModel) {
it?.let {
NoteCompose(
it,
@@ -2052,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
}
@@ -2072,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
}
@@ -2212,7 +2205,8 @@ private fun EmojiListOptions(
accountViewModel.userProfile().pubkeyHex,
"",
null
)
),
accountViewModel
) {
it?.let { usersEmojiList ->
val hasAddedThis by usersEmojiList.live().metadata.map {
@@ -2315,7 +2309,7 @@ fun RenderPinListEvent(
TranslatableRichTextViewer(
content = pin,
canPreview = true,
tags = remember { ImmutableListOfLists() },
tags = EmptyTagList,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -2414,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
@@ -2564,6 +2558,7 @@ fun SecondUserInfoRow(
@Composable
fun LoadStatuses(
user: User,
accountViewModel: AccountViewModel,
content: @Composable (ImmutableList<AddressableNote>) -> Unit
) {
var statuses: ImmutableList<AddressableNote> by remember {
@@ -2573,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
}
@@ -2586,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)
@@ -2601,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
@@ -2635,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) {
@@ -2752,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
@@ -2765,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)
}
}
@@ -2773,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
}
@@ -2828,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
)
}
}
@@ -2874,7 +2888,7 @@ fun DisplayHighlight(
quote,
canPreview = canPreview && !makeItShort,
remember { Modifier.fillMaxWidth() },
remember { ImmutableListOfLists<String>(emptyList()) },
EmptyTagList,
backgroundColor,
accountViewModel,
nav
@@ -2891,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
}
}
}
@@ -2923,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
@@ -3332,7 +3343,7 @@ fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: Acco
val blurHash = event.blurhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content
val description = event.alt() ?: event.content
val isImage = imageExtensions.any { fullUrl.split("?")[0].lowercase().endsWith(it) }
val uri = note.toNostrUri()
@@ -3369,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)
}
@@ -3396,7 +3407,7 @@ private fun ObserverAndRenderNIP95(
val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) }
val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.content
val description = eventHeader.alt() ?: eventHeader.content
val mimeType = eventHeader.mimeType()
val newContent = if (mimeType?.startsWith("image") == true) {
@@ -3444,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
}
}
@@ -3524,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 }
@@ -3654,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
}
@@ -3739,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

View File

@@ -33,6 +33,7 @@ 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
@@ -116,7 +117,7 @@ private fun OptionNote(
nav: (String) -> Unit
) {
val tags = remember(baseNote) {
baseNote.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
baseNote.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList
}
Row(
@@ -307,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(

View File

@@ -83,14 +83,12 @@ 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
@@ -136,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
@@ -252,7 +251,6 @@ private fun LoadAndDisplayZapraiser(
}
if (zapraiserAmount > 0) {
Spacer(modifier = Height4dpModifier)
Box(
modifier = remember {
ReactionRowZapraiserSize
@@ -460,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()) {
@@ -546,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()
}
}
}
}
@@ -655,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()
}
}
}
}
@@ -678,6 +697,11 @@ fun BoostReaction(
onQuote = {
wantsToBoost = false
onQuotePress()
},
onRepost = {
scope.launch(Dispatchers.IO) {
accountViewModel.boost(baseNote)
}
}
)
}
@@ -741,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
}
}
)
},
@@ -860,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 {
@@ -873,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) {
@@ -1084,7 +1127,7 @@ private fun zapClick(
)
.show()
}
} else if (!accountViewModel.isWriteable()) {
} else if (!accountViewModel.isWriteable() && !accountViewModel.loggedInWithExternalSigner()) {
scope.launch {
Toast
.makeText(
@@ -1224,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()
}
@@ -1239,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()
}
},
@@ -1318,18 +1366,14 @@ private fun ActionableReactionButton(
onChangeAmount: () -> Unit,
toRemove: Set<String>
) {
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.reactToOrDelete(
baseNote,
reactionType
)
onDismiss()
}
accountViewModel.reactToOrDelete(
baseNote,
reactionType
)
onDismiss()
},
shape = ButtonBorder,
colors = ButtonDefaults
@@ -1340,13 +1384,11 @@ private fun ActionableReactionButton(
val thisModifier = remember(reactionType) {
Modifier.combinedClickable(
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.reactToOrDelete(
baseNote,
reactionType
)
onDismiss()
}
accountViewModel.reactToOrDelete(
baseNote,
reactionType
)
onDismiss()
},
onLongClick = {
onChangeAmount()
@@ -1423,7 +1465,7 @@ fun ZapAmountChoicePopup(
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 = ""

View File

@@ -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)
}

View File

@@ -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
},

View File

@@ -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
@@ -110,7 +112,7 @@ fun UserPicture(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadUser(baseUserHex = userHex) {
LoadUser(baseUserHex = userHex, accountViewModel) {
if (it != null) {
UserPicture(
user = it,
@@ -214,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))
}
@@ -409,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()
@@ -469,20 +475,98 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
}
Divider()
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePrivateBookmark(note); onDismiss() } }) {
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 = { scope.launch(Dispatchers.IO) { accountViewModel.addPrivateBookmark(note); onDismiss() } }) {
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) { accountViewModel.removePublicBookmark(note); onDismiss() } }) {
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) { accountViewModel.addPublicBookmark(note); onDismiss() } }) {
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))
}
}

View File

@@ -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(

View File

@@ -4,10 +4,24 @@ 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
@@ -33,6 +47,7 @@ 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
@@ -93,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(
@@ -130,7 +144,6 @@ fun ZapCustomDialog(
onPayViaIntent = onPayViaIntent,
zapType = selectedZapType
)
onClose()
}
}
@@ -168,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
},

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(),

View File

@@ -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()
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope
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
@@ -24,15 +25,20 @@ import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker
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.Participant
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.UserMetadata
@@ -40,6 +46,7 @@ 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.launch
@@ -47,7 +54,7 @@ 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 }
@@ -97,6 +104,10 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.isWriteable()
}
fun loggedInWithExternalSigner(): Boolean {
return account.loginWithExternalSigner
}
fun userProfile(): User {
return account.userProfile()
}
@@ -106,11 +117,13 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
fun reactToOrDelete(note: Note, reaction: String) {
val currentReactions = account.reactionTo(note, reaction)
if (currentReactions.isNotEmpty()) {
account.delete(currentReactions)
} else {
account.reactTo(note, reaction)
viewModelScope.launch(Dispatchers.IO) {
val currentReactions = account.reactionTo(note, reaction)
if (currentReactions.isNotEmpty()) {
account.delete(currentReactions)
} else {
account.reactTo(note, reaction)
}
}
}
@@ -171,6 +184,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?,
@@ -185,6 +230,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
@@ -251,10 +298,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)
}
@@ -366,7 +429,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
@@ -503,6 +566,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

View File

@@ -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) }

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -2,32 +2,37 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.animation.with
import androidx.compose.foundation.background
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
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -37,12 +42,7 @@ 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.navigation.Route.Companion.InvertedLayouts
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -57,8 +57,9 @@ import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import kotlinx.coroutines.launch
import kotlin.math.abs
@OptIn(ExperimentalMaterialApi::class)
@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class)
@Composable
fun MainScreen(
accountViewModel: AccountViewModel,
@@ -194,6 +195,33 @@ fun MainScreen(
}
}
val bottomBarHeightPx = with(LocalDensity.current) { 50.dp.roundToPx().toFloat() }
val bottomBarOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = bottomBarOffsetHeightPx.value + delta
val currentRoute = navState.value?.destination?.route
bottomBarOffsetHeightPx.value = if (currentRoute !in InvertedLayouts) {
newOffset.coerceIn(-bottomBarHeightPx, 0f)
} else {
newOffset.coerceIn(0f, bottomBarHeightPx)
}
return Offset.Zero
}
}
}
val shouldShow = remember {
derivedStateOf {
abs(bottomBarOffsetHeightPx.value) < bottomBarHeightPx / 2.0f
}
}
ModalBottomSheetLayout(
sheetState = sheetState,
sheetContent = {
@@ -203,12 +231,40 @@ fun MainScreen(
Scaffold(
modifier = Modifier
.background(MaterialTheme.colors.primaryVariant)
.statusBarsPadding(),
.statusBarsPadding()
.nestedScroll(nestedScrollConnection),
bottomBar = {
AppBottomBar(accountViewModel, navState, navBottomRow)
AnimatedContent(
targetState = shouldShow.value,
transitionSpec = {
slideInVertically { height -> height } togetherWith
slideOutVertically { height -> height }
}
) { isVisible ->
if (isVisible) {
AppBottomBar(accountViewModel, navState, navBottomRow)
}
}
},
topBar = {
AppTopBar(followLists, navState, scaffoldState, accountViewModel, nav = nav, navPopBack)
AnimatedContent(
targetState = shouldShow.value,
transitionSpec = {
slideInVertically { height -> 0 } togetherWith
slideOutVertically { height -> 0 }
}
) { isVisible ->
if (isVisible) {
AppTopBar(
followLists,
navState,
scaffoldState,
accountViewModel,
nav = nav,
navPopBack
)
}
}
},
drawerContent = {
DrawerContent(nav, scaffoldState, sheetState, accountViewModel)
@@ -217,11 +273,27 @@ fun MainScreen(
}
},
floatingActionButton = {
FloatingButtons(navState, accountViewModel, accountStateViewModel, nav, navBottomRow)
Crossfade(
targetState = shouldShow.value,
animationSpec = tween(durationMillis = 100)
) { state ->
if (state) {
FloatingButtons(
navState,
accountViewModel,
accountStateViewModel,
nav,
navBottomRow
)
}
}
},
scaffoldState = scaffoldState
) {
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
Column(
modifier = Modifier
.padding(bottom = it.calculateBottomPadding())
) {
AppNavigation(
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
@@ -251,11 +323,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.

View File

@@ -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))
}
}

View File

@@ -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) }

View File

@@ -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

View File

@@ -127,7 +127,6 @@ fun WatchAccountForVideoScreen(videoFeedView: NostrVideoFeedViewModel, accountVi
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SaveableFeedState(
videoFeedView: NostrVideoFeedViewModel,
@@ -446,14 +445,11 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
Spacer(modifier = Modifier.height(8.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) {
val scope = rememberCoroutineScope()
ReplyReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
scope.launch {
routeFor(
baseNote,
accountViewModel.userProfile()
)?.let { nav(it) }
}
routeFor(
baseNote,
accountViewModel.userProfile()
)?.let { nav(it) }
}
BoostReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -5,13 +5,13 @@
<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="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="explicit_content">খোলামেলা আধেয়</string>
<string name="spam">স্প্যাম</string>
<string name="impersonation">ছদ্মবেশ</string>
<string name="illegal_behavior">বেআইনী আচরণ</string>
@@ -23,19 +23,19 @@
<string name="copy_note_id">নোটের আইডি কপি করুন</string>
<string name="broadcast">সম্প্রচার</string>
<string name="request_deletion">নোটটি মুছে ফেলার অনুরোধ করুন</string>
<string name="block_report">ব্লক/রিপোর্ট</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_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="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="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>
@@ -48,17 +48,17 @@
<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="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="lightning_invoice">বিজলি-ইনভয়েস</string>
<string name="pay">পরিশোধ করুন</string>
<string name="lightning_tips">লাইটনিং টিপস</string>
<string name="note_to_receiver">প্রাপকের উদ্দেশ্যে চিরকুট</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>
@@ -76,11 +76,11 @@
<string name="create">তৈরি করুন</string>
<string name="cancel">বাতিল করুন</string>
<string name="failed_to_upload_the_image">ছবিটি সফলভাবে আপলোড করা যায় নি</string>
<string name="relay_address">রিলে ঠিকানা</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="home_feed">নীড়পাতা</string>
<string name="private_message_feed">ব্যক্তিগত বার্তাসমূহ</string>
<string name="public_chat_feed">সার্বজনীন কথোপকথন</string>
<string name="global_feed">বৈশ্বিক পাতা</string>
@@ -94,13 +94,13 @@
<string name="avatar_url">অবতারের URL</string>
<string name="banner_url">ব্যানারের URL</string>
<string name="website_url">ওয়েবসাইটের URL</string>
<string name="ln_address">LN ঠিকানা</string>
<string name="ln_url_outdated">LN 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="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>
@@ -118,35 +118,35 @@
<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 ID (তথা পাসওয়ার্ড) ক্লিপবোর্ডে কপি করা হলো</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="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="unblock">আনব্লক করু</string>
<string name="copy_user_id">ব্যবহারকারীর আইডি কপি করুন</string>
<string name="unblock_user">ব্যবহারকারীর উপর থেকে অবরোধ তুলে নি</string>
<string name="npub_hex_username">"এন-পাব, ইউজারনেম, টেক্সট"</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">এন-সেক অথবা এন-পাব..</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="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="key_is_required">চাবি প্রয়োজন</string>
<string name="login">লগ ইন</string>
<string name="generate_a_new_key">একটি নতুন ি তৈরি করুন</string>
<string name="loading_feed">ফিডটি লোড করা হচ্ছে</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="feed_is_empty">পাতাটি খালি</string>
<string name="refresh">রিফ্রেশ করুন</string>
<string name="created">তৈরি করা হয়েছে</string>
<string name="with_description_of">এর বিবরণ সহ</string>
@@ -165,7 +165,7 @@
<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_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>
@@ -207,7 +207,7 @@
<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="account_switch_pubkey_only">শুধুমাত্র পাঠযোগ্য, ব্যক্তিগত চাবি নেই</string>
<string name="back">পিছে যান</string>
<string name="quick_action_select">নির্বাচন করুন</string>
<string name="quick_action_share_browser_link">ব্রাউজারের লিংক শেয়ার করুন</string>
@@ -220,9 +220,9 @@
<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_block_dialog_btn">ব্লক করুন</string>
<string name="quick_action_delete_dialog_btn">মুছে ফেলুন</string>
<string name="quick_action_block">অবরুদ্ধ করুন</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>
@@ -231,8 +231,8 @@
<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_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>
@@ -240,22 +240,22 @@
<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="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="public_bookmarks">সর্বজনীন বুকমার্ক</string>
<string name="add_to_private_bookmarks">ব্যক্তিগত বুকমার্কে যুক্ত করুন</string>
<string name="add_to_public_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="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_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">এন-সেক/হেক্স প্রাইভেট কি</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>
@@ -274,14 +274,14 @@
<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">একটি সর্বজনীন বার্তা পাঠান</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="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>
@@ -290,16 +290,16 @@
<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">সর্বজনীন</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="zap_type_nonzap_explainer">নস্টারে কোনো সন্ধান নেই, কেবল বিজলিতে আছে</string>
<string name="file_server">ফাইল সার্ভার</string>
<string name="zap_forward_lnAddress">LN ঠিকানা বা @ইউজার</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>
@@ -337,7 +337,7 @@
\n৩. Orbot এ ঢুকে Socks পোর্টটি খেয়াল করুন। এটি ৯০৫০ তে নির্দিষ্ট করা থাকে
\n. প্রয়োজনে Orbot এ পোর্টটি বদলে দিন
\n৫. স্ক্রিনের Socks পোর্টটি প্রস্তুত করুন
\n৬. Orbot কে প্রক্সি হিসেবে ব্যবহার করতে Activate বাটনটি চাপুন
\n৬. Orbot কে প্রক্সি হিসেবে ব্যবহার করতে Activate বোতামটি চাপুন
</string>
<string name="orbot_socks_port">Orbot এর Socks পোর্ট</string>
<string name="invalid_port_number">পোর্ট নম্বরটি অকার্যকর</string>
@@ -362,12 +362,62 @@
<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">সমর্থিত NIPs</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>
@@ -385,4 +435,69 @@
<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>

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>

View File

@@ -473,7 +473,6 @@
<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>

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>

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>
@@ -557,7 +561,7 @@
<string name="error_dialog_zap_error">Unable to send zap</string>
<string name="error_dialog_talk_to_user">Message the User</string>
<string name="error_dialog_button_ok">Ok</string>
<string name="error_dialog_button_ok">OK</string>
<string name="relay_information_document_error_assemble_url">Failed to reach %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Failed to reach %1$s: %2$s</string>
@@ -584,4 +588,6 @@
<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>

View File

@@ -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())

View File

@@ -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)
// }
}
}
}

View File

@@ -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)

View File

@@ -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!!

View File

@@ -15,7 +15,7 @@ buildscript {
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'
}
}

View File

@@ -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()

View File

@@ -16,9 +16,37 @@ class BookmarkListEvent(
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
var decryptedContent = ""
companion object {
const val kind = 30001
fun create(
name: String = "",
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
content: String,
pubKey: HexKey,
createdAt: Long = TimeUtils.now()
): BookmarkListEvent {
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
events?.forEach {
tags.add(listOf("e", it))
}
users?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
return BookmarkListEvent(id.toHexKey(), pubKey, createdAt, tags, content, "")
}
fun create(
name: String = "",
@@ -53,5 +81,11 @@ class BookmarkListEvent(
val sig = CryptoUtils.sign(id, privateKey)
return BookmarkListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
fun create(
unsignedEvent: BookmarkListEvent, signature: String
): BookmarkListEvent {
return BookmarkListEvent(unsignedEvent.id, unsignedEvent.pubKey, unsignedEvent.createdAt, unsignedEvent.tags, unsignedEvent.content, signature)
}
}
}

View File

@@ -2,10 +2,12 @@ package com.vitorpamplona.quartz.events
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.key
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
@Immutable
@@ -27,7 +29,7 @@ class ChannelCreateEvent(
companion object {
const val kind = 40
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelCreateEvent {
fun create(channelInfo: ChannelData?, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ChannelCreateEvent {
val content = try {
if (channelInfo != null) {
mapper.writeValueAsString(channelInfo)
@@ -39,11 +41,15 @@ class ChannelCreateEvent(
""
}
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val pubKey = keyPair.pubKey.toHexKey()
val tags = emptyList<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
val sig = if (keyPair.privKey == null) null else CryptoUtils.sign(id, keyPair.pubKey)
return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig?.toHexKey() ?: "")
}
fun create(unsignedEvent: ChannelCreateEvent, signature: String): ChannelCreateEvent {
return ChannelCreateEvent(unsignedEvent.id, unsignedEvent.pubKey, unsignedEvent.createdAt, unsignedEvent.tags, unsignedEvent.content, signature)
}
}

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
@Immutable
@@ -33,14 +34,13 @@ class ChannelMessageEvent(
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: List<ZapSplitSetup>? = null,
privateKey: ByteArray,
keyPair: KeyPair,
createdAt: Long = TimeUtils.now(),
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null
): ChannelMessageEvent {
val content = message
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
listOf("e", channel, "", "root")
)
@@ -63,9 +63,16 @@ class ChannelMessageEvent(
tags.add(listOf("g", it))
}
val pubKey = keyPair.pubKey.toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return ChannelMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
val sig = if (keyPair.privKey == null) null else CryptoUtils.sign(id, keyPair.privKey)
return ChannelMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig?.toHexKey() ?: "")
}
fun create(
unsignedEvent: ChannelMessageEvent, signature: String
): ChannelMessageEvent {
return ChannelMessageEvent(unsignedEvent.id, unsignedEvent.pubKey, unsignedEvent.createdAt, unsignedEvent.tags, unsignedEvent.content, signature)
}
}
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
@Immutable
@@ -29,7 +30,7 @@ class ChannelMetadataEvent(
companion object {
const val kind = 41
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelMetadataEvent {
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ChannelMetadataEvent {
val content =
if (newChannelInfo != null) {
mapper.writeValueAsString(newChannelInfo)
@@ -37,11 +38,15 @@ class ChannelMetadataEvent(
""
}
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val pubKey = keyPair.pubKey.toHexKey()
val tags = listOf(listOf("e", originalChannelIdHex, "", "root"))
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
val sig = if (keyPair.privKey == null) null else CryptoUtils.sign(id, keyPair.privKey)
return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig?.toHexKey() ?: "")
}
fun create(unsignedEvent: ChannelMetadataEvent, signature: String): ChannelMetadataEvent {
return ChannelMetadataEvent(unsignedEvent.id, unsignedEvent.pubKey, unsignedEvent.createdAt, unsignedEvent.tags, unsignedEvent.content, signature)
}
}
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
@@ -61,7 +62,7 @@ class ChatMessageEvent(
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
privateKey: ByteArray,
keyPair: KeyPair,
createdAt: Long = TimeUtils.now()
): ChatMessageEvent {
val content = msg
@@ -91,10 +92,17 @@ class ChatMessageEvent(
tags.add(listOf("subject", it))
}
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val pubKey = keyPair.pubKey.toHexKey()
val id = generateId(pubKey, createdAt, ClassifiedsEvent.kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return ChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
val sig = if (keyPair.privKey == null) null else CryptoUtils.sign(id, keyPair.privKey)
return ChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig?.toHexKey() ?: "")
}
fun create(
unsignedEvent: ChatMessageEvent,
signature: String
): ChatMessageEvent {
return ChatMessageEvent(unsignedEvent.id, unsignedEvent.pubKey, unsignedEvent.createdAt, unsignedEvent.tags, unsignedEvent.content, signature)
}
}
}

View File

@@ -7,6 +7,7 @@ import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.decodePublicKey
@@ -98,7 +99,7 @@ class ContactListEvent(
followCommunities: List<ATag>,
followEvents: List<String>,
relayUse: Map<String, ReadWrite>?,
privateKey: ByteArray,
keyPair: KeyPair,
createdAt: Long = TimeUtils.now()
): ContactListEvent {
val content = if (relayUse != null) {
@@ -130,122 +131,122 @@ class ContactListEvent(
return create(
content = content,
tags = tags,
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun followUser(earlierVersion: ContactListEvent, pubKeyHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun followUser(earlierVersion: ContactListEvent, pubKeyHex: String, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOf("p", pubKeyHex)),
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun unfollowUser(earlierVersion: ContactListEvent, pubKeyHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun unfollowUser(earlierVersion: ContactListEvent, pubKeyHex: String, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (!earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex },
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun followHashtag(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun followHashtag(earlierVersion: ContactListEvent, hashtag: String, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (earlierVersion.isTaggedHash(hashtag)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOf("t", hashtag)),
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun unfollowHashtag(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun unfollowHashtag(earlierVersion: ContactListEvent, hashtag: String, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (!earlierVersion.isTaggedHash(hashtag)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) },
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun followGeohash(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun followGeohash(earlierVersion: ContactListEvent, hashtag: String, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (earlierVersion.isTaggedGeoHash(hashtag)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOf("g", hashtag)),
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun unfollowGeohash(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun unfollowGeohash(earlierVersion: ContactListEvent, hashtag: String, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (!earlierVersion.isTaggedGeoHash(hashtag)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag },
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun followEvent(earlierVersion: ContactListEvent, idHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun followEvent(earlierVersion: ContactListEvent, idHex: String, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (earlierVersion.isTaggedEvent(idHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOf("e", idHex)),
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun unfollowEvent(earlierVersion: ContactListEvent, idHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun unfollowEvent(earlierVersion: ContactListEvent, idHex: String, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (!earlierVersion.isTaggedEvent(idHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != idHex },
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun followAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun followAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOfNotNull("a", aTag.toTag(), aTag.relay)),
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun unfollowAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun unfollowAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() },
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun updateRelayList(earlierVersion: ContactListEvent, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun updateRelayList(earlierVersion: ContactListEvent, relayUse: Map<String, ReadWrite>?, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): ContactListEvent {
val content = if (relayUse != null) {
mapper.writeValueAsString(relayUse)
} else {
@@ -255,16 +256,35 @@ class ContactListEvent(
return create(
content = content,
tags = earlierVersion.tags,
privateKey = privateKey,
keyPair = keyPair,
createdAt = createdAt
)
}
fun create(content: String, tags: List<List<String>>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
fun create(
unsignedEvent: Event,
signature: String
): ContactListEvent {
return ContactListEvent(
unsignedEvent.id,
unsignedEvent.pubKey,
unsignedEvent.createdAt,
unsignedEvent.tags,
unsignedEvent.content,
signature
)
}
fun create(
content: String,
tags: List<List<String>>,
keyPair: KeyPair,
createdAt: Long = TimeUtils.now()
): ContactListEvent {
val pubKey = keyPair.pubKey.toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
val sig = if (keyPair.privKey == null) null else CryptoUtils.sign(id, keyPair.privKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig?.toHexKey() ?: "")
}
}
@@ -330,6 +350,8 @@ class UserMetadata {
@Stable
data class ImmutableListOfLists<T>(val lists: List<List<T>> = emptyList())
val EmptyTagList = ImmutableListOfLists<String>(emptyList())
fun List<List<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> {
return ImmutableListOfLists(this)
}

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
@Immutable
@@ -20,13 +21,17 @@ class DeletionEvent(
companion object {
const val kind = 5
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): DeletionEvent {
fun create(deleteEvents: List<String>, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): DeletionEvent {
val content = ""
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val pubKey = keyPair.pubKey.toHexKey()
val tags = deleteEvents.map { listOf("e", it) }
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return DeletionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
val sig = if (keyPair.privKey == null) null else CryptoUtils.sign(id, keyPair.privKey)
return DeletionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig?.toHexKey() ?: "")
}
fun create(unsignedEvent: DeletionEvent, signature: String): DeletionEvent {
return DeletionEvent(unsignedEvent.id, unsignedEvent.pubKey, unsignedEvent.createdAt, unsignedEvent.tags, unsignedEvent.content, signature)
}
}
}

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
@@ -21,11 +22,11 @@ class EmojiPackSelectionEvent(
fun create(
listOfEmojiPacks: List<ATag>?,
privateKey: ByteArray,
keyPair: KeyPair,
createdAt: Long = TimeUtils.now()
): EmojiPackSelectionEvent {
val msg = ""
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val pubKey = keyPair.pubKey.toHexKey()
val tags = mutableListOf<List<String>>()
listOfEmojiPacks?.forEach {
@@ -33,8 +34,14 @@ class EmojiPackSelectionEvent(
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = CryptoUtils.sign(id, privateKey)
return EmojiPackSelectionEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
val sig = if (keyPair.privKey == null) null else CryptoUtils.sign(id, keyPair.privKey)
return EmojiPackSelectionEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig?.toHexKey() ?: "")
}
fun create(
unsignedEvent: EmojiPackSelectionEvent, signature: String
): EmojiPackSelectionEvent {
return EmojiPackSelectionEvent(unsignedEvent.id, unsignedEvent.pubKey, unsignedEvent.createdAt, unsignedEvent.tags, unsignedEvent.content, signature)
}
}
}

View File

@@ -96,16 +96,20 @@ open class Event(
override fun hasZapSplitSetup() = tags.any { it.size > 1 && it[0] == "zap" }
override fun zapSplitSetup(): List<ZapSplitSetup> {
return tags.filter { it.size > 1 && it[0] == "zap" }.map {
return tags.filter { it.size > 1 && it[0] == "zap" }.mapNotNull {
val isLnAddress = it[0].contains("@") || it[0].startsWith("LNURL", true)
val weight = if (isLnAddress) 1.0 else (it.getOrNull(3)?.toDoubleOrNull() ?: 0.0)
ZapSplitSetup(
it[1],
it.getOrNull(2),
weight,
isLnAddress
)
if (weight > 0) {
ZapSplitSetup(
it[1],
it.getOrNull(2),
weight,
isLnAddress
)
} else {
null
}
}
}

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
@Immutable
@@ -21,6 +22,7 @@ class FileHeaderEvent(
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
@@ -40,11 +42,12 @@ class FileHeaderEvent(
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
private const val ALT = "alt"
fun create(
url: String,
mimeType: String? = null,
description: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
@@ -53,17 +56,19 @@ class FileHeaderEvent(
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
privateKey: ByteArray,
keyPair: KeyPair,
createdAt: Long = TimeUtils.now()
): FileHeaderEvent {
val tags = listOfNotNull(
listOf(URL, url),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
alt?.let { listOf(ALT, it) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
@@ -75,11 +80,11 @@ class FileHeaderEvent(
}
)
val content = description ?: ""
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val content = alt ?: ""
val pubKey = keyPair.pubKey.toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
val sig = if (keyPair.privKey == null) null else CryptoUtils.sign(id, keyPair.privKey)
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig?.toHexKey() ?: "")
}
}
}

View File

@@ -40,6 +40,21 @@ class FileStorageEvent(
return Base64.getEncoder().encodeToString(bytes)
}
fun create(
mimeType: String,
data: ByteArray,
pubKey: HexKey,
createdAt: Long = TimeUtils.now()
): FileStorageEvent {
val tags = listOfNotNull(
listOf(TYPE, mimeType)
)
val content = encode(data)
val id = generateId(pubKey, createdAt, kind, tags, content)
return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, "")
}
fun create(
mimeType: String,
data: ByteArray,

View File

@@ -22,6 +22,7 @@ class FileStorageHeaderEvent(
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
@@ -38,11 +39,12 @@ class FileStorageHeaderEvent(
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
private const val ALT = "alt"
fun create(
storageEvent: FileStorageEvent,
mimeType: String? = null,
description: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
@@ -51,12 +53,13 @@ class FileStorageHeaderEvent(
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
privateKey: ByteArray,
pubKey: HexKey,
createdAt: Long = TimeUtils.now()
): FileStorageHeaderEvent {
val tags = listOfNotNull(
listOf("e", storageEvent.id),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
alt?.let { listOf(ALT, it) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
@@ -73,7 +76,47 @@ class FileStorageHeaderEvent(
}
)
val content = description ?: ""
val content = alt ?: ""
val id = generateId(pubKey, createdAt, kind, tags, content)
return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, "")
}
fun create(
storageEvent: FileStorageEvent,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): FileStorageHeaderEvent {
val tags = listOfNotNull(
listOf("e", storageEvent.id),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
alt?.let { listOf(ALT, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
listOf("content-warning", "")
} else {
null
}
}
)
val content = alt ?: ""
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)

View File

@@ -52,15 +52,37 @@ abstract class GeneralListEvent(
return privateTagsCache
}
fun privateTags(content: String): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
content.let { mapper.readValue<List<List<String>>>(it) }
} catch (e: Throwable) {
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTagsOrEmpty(privKey: ByteArray): List<List<String>> {
return privateTags(privKey) ?: emptyList()
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
fun privateTagsOrEmpty(content: String): List<List<String>> {
return privateTags(content) ?: emptyList()
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
fun privateTaggedUsers(content: String) = privateTags(content)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
fun privateHashtags(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "t" }?.map { it[1] }
fun privateHashtags(content: String) = privateTags(content)?.filter { it.size > 1 && it[0] == "t" }?.map { it[1] }
fun privateGeohashes(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "g" }?.map { it[1] }
fun privateGeohashes(content: String) = privateTags(content)?.filter { it.size > 1 && it[0] == "g" }?.map { it[1] }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
fun privateTaggedEvents(content: String) = privateTags(content)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
@@ -68,6 +90,13 @@ abstract class GeneralListEvent(
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun privateTaggedAddresses(content: String) = privateTags(content)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
fun createPrivateTags(
privEvents: List<String>? = null,

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
@Immutable
@@ -28,13 +29,13 @@ class GenericRepostEvent(
companion object {
const val kind = 16
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): GenericRepostEvent {
fun create(boostedPost: EventInterface, keyPair: KeyPair, createdAt: Long = TimeUtils.now()): GenericRepostEvent {
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id())
val replyToAuthor = listOf("p", boostedPost.pubKey())
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val pubKey = keyPair.pubKey.toHexKey()
var tags: List<List<String>> = listOf(replyToPost, replyToAuthor)
if (boostedPost is AddressableEvent) {
@@ -44,8 +45,12 @@ class GenericRepostEvent(
tags = tags + listOf(listOf("k", "${boostedPost.kind()}"))
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return GenericRepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
val sig = if (keyPair.privKey == null) null else CryptoUtils.sign(id, keyPair.privKey)
return GenericRepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig?.toHexKey() ?: "")
}
fun create(unsignedEvent: GenericRepostEvent, signature: String): GenericRepostEvent {
return GenericRepostEvent(unsignedEvent.id, unsignedEvent.pubKey, unsignedEvent.createdAt, unsignedEvent.tags, unsignedEvent.content, signature)
}
}
}

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