Compare commits

...

299 Commits

Author SHA1 Message Date
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
Vitor Pamplona
536fb49bc3 0.76.1 2023-09-15 17:15:54 -04:00
Vitor Pamplona
edd93141bc No need for the @ when adding users to the Split 2023-09-15 17:13:35 -04:00
Vitor Pamplona
20c2d19a9c Refining the Payment via Intent 2023-09-15 17:08:19 -04:00
Vitor Pamplona
17b2fe9cd8 Merge pull request #596 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-15 14:22:41 -04:00
Crowdin Bot
134e7cee0c New Crowdin translations by GitHub Action 2023-09-15 16:59:32 +00:00
Vitor Pamplona
108ddffcc7 formatting 2023-09-15 12:56:50 -04:00
Vitor Pamplona
20e76daff8 Adds zap splits 2023-09-15 12:56:24 -04:00
greenart7c3
6876a7f0d2 zaps in background 2023-09-15 13:53:05 -03:00
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
Vitor Pamplona
7bee44c5ad Merge pull request #595 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-14 14:01:54 -04:00
Crowdin Bot
a32e90aed0 New Crowdin translations by GitHub Action 2023-09-14 17:48:43 +00:00
Vitor Pamplona
310db1008e Fixes bug when verifying the checksum of LNURLs (bech32) in uppercase. 2023-09-14 13:46:29 -04:00
greenart7c3
21560d098e fix upload image 2023-09-13 19:15:00 -03:00
greenart7c3
33f7c5c82e deleted intentutils 2023-09-13 18:06:50 -03:00
greenart7c3
52f17e9f46 nwc support with amber 2023-09-13 16:23:56 -03:00
greenart7c3
1e7ffa365b change relay auth to use signevent 2023-09-13 16:08:38 -03:00
greenart7c3
e00fc83e5c fix reaction row and zaps 2023-09-13 15:54:44 -03:00
greenart7c3
a2fcb3539e Merge branch 'main' into amber 2023-09-13 13:15:45 -03:00
Vitor Pamplona
cb69d056b8 Allowing playback to save any position and not only after 5 seconds. 2023-09-13 12:12:47 -04:00
Vitor Pamplona
e7f950cd32 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-13 11:45:27 -04:00
Vitor Pamplona
3b2686ffeb Removes potential bug when the list contains an e tag with an a reference 2023-09-13 11:45:17 -04:00
Vitor Pamplona
2387a609df Merge pull request #593 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-13 10:33:47 -04:00
Crowdin Bot
6457a84f4b New Crowdin translations by GitHub Action 2023-09-13 14:30:48 +00:00
Vitor Pamplona
dee231d43a Moves deletion and report to be the last item on the dropdown menu: https://image.nostr.build/709c4f2753cf6a40f9f9322379394d09354acb7e88502b64be348e4a3030734b.png 2023-09-13 10:27:37 -04:00
greenart7c3
8c4af4ac85 only decrypt giftwrap in the dms screen 2023-09-13 11:10:10 -03:00
greenart7c3
58cf0d9f5d fix freeze when going to dms screen 2023-09-13 10:08:43 -03:00
greenart7c3
de5c0d4681 do not try decrypt private zaps for now 2023-09-13 09:32:24 -03:00
greenart7c3
780832d785 fix block/unblock 2023-09-13 09:22:34 -03:00
greenart7c3
b8808a17b5 fix bookmarks decryption 2023-09-13 08:38:20 -03:00
greenart7c3
5001e58c2b improve giftwrap decryption 2023-09-13 08:04:51 -03:00
greenart7c3
95514b66ea improve giftwrap decryption 2023-09-13 08:01:46 -03:00
greenart7c3
90c6d6c8f8 fix login with amber 2023-09-13 06:42:37 -03:00
Vitor Pamplona
88d2ce3ad0 Updating dependencies 2023-09-12 17:53:18 -04:00
Vitor Pamplona
484f3a6842 Merge pull request #590 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-12 08:32:16 -04:00
Crowdin Bot
866caf8bc5 New Crowdin translations by GitHub Action 2023-09-12 12:12:41 +00:00
Vitor Pamplona
be9e3c3bf7 Merge pull request #589 from davotoula/main
Updated translations for cs/de/sv
2023-09-12 08:11:02 -04:00
David Kaspar
49a3cb1978 Updated translations for cs/de/sv 2023-09-12 11:41:43 +02:00
greenart7c3
c3ddc4f7ad fix release build not working 2023-09-11 18:12:55 -03:00
greenart7c3
ff4d704bde add support for private zaps 2023-09-11 15:30:00 -03:00
greenart7c3
a773be12c4 decrypt LnZapRequestEvent 2023-09-11 11:52:53 -03:00
greenart7c3
7ff6f16df0 Merge branch 'main' into amber 2023-09-11 07:20:03 -03:00
Vitor Pamplona
d9053f580f Merge pull request #586 from OrigiSize/patch-1
Minor README.md edits
2023-09-09 19:37:57 -04:00
OrigiSize
fee2764338 Minor README.md edits
Fixing grammar and flow
2023-09-09 12:33:09 -07:00
Vitor Pamplona
61cb4fdd74 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-09 14:42:59 -04:00
Vitor Pamplona
d89869ebff Merge pull request #585 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-09 14:40:45 -04:00
Vitor Pamplona
8518819dfa Forces valid hexes for users and notes 2023-09-09 14:39:41 -04:00
Crowdin Bot
0a8923be6b New Crowdin translations by GitHub Action 2023-09-09 18:34:33 +00:00
Vitor Pamplona
cda821e5af Stops accepting space as a Valid hex char and requires an even number of chars (padding) 2023-09-09 14:33:19 -04:00
Vitor Pamplona
be2402702f Forces the ZapRequest to exist when processing the LnZapEvent. 2023-09-09 14:32:27 -04:00
Vitor Pamplona
d723a8ab5c Refactors the split between nip19 and the rest of the text. 2023-09-09 14:26:20 -04:00
Vitor Pamplona
5c882c81c8 Account is never null here. 2023-09-09 14:24:29 -04:00
Vitor Pamplona
24d2709564 Merge pull request #584 from atrifat/move-broadcast-button-away
Fix vitorpamplona/amethyst#550 - change broadcast button position
2023-09-08 20:54:46 -04:00
Rif'at Ahdi R
ce15bc46a7 Fix vitorpamplona/amethyst#550 - change broadcast button position 2023-09-09 08:24:17 +08:00
Vitor Pamplona
de4bc30730 Merge pull request #583 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-08 16:19:48 -04:00
Crowdin Bot
64f6811c00 New Crowdin translations by GitHub Action 2023-09-08 20:18:47 +00:00
Vitor Pamplona
e05ab104b9 0.76.0 2023-09-08 16:16:53 -04:00
Vitor Pamplona
b4f1c4d13e Migrates to Encrypted Push Notifications 2023-09-08 16:16:26 -04:00
greenart7c3
36a3c4123c Merge branch 'main' into amber 2023-09-08 15:48:13 -03:00
Vitor Pamplona
939eb1bd8d Fixes the lack of permission when using Android's PhotoPicker to upload videos (thumbnail fails to load and videos don't play because the Playback service doesn't have permission to run) 2023-09-08 11:39:14 -04:00
Vitor Pamplona
854469cc7d Merge pull request #582 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-08 09:21:01 -04:00
Crowdin Bot
338ae41baa New Crowdin translations by GitHub Action 2023-09-08 12:42:58 +00:00
Vitor Pamplona
6cae89d276 Adds indonesian to crowdin 2023-09-08 08:41:07 -04:00
Vitor Pamplona
15c1e25122 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-08 08:37:20 -04:00
Vitor Pamplona
c5716416b6 Merge pull request #581 from atrifat/add-indonesian-translation
Add Indonesian Translation
2023-09-08 08:36:56 -04:00
Rif'at Ahdi R
65e4a766ab Add Indonesian Translation 2023-09-08 11:39:27 +08:00
Vitor Pamplona
c2a4c9bbb3 Improvements to the relay settings page. 2023-09-07 19:07:33 -04:00
Vitor Pamplona
b042110154 Merge pull request #579 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-07 15:39:07 -04:00
Crowdin Bot
da97b5593b New Crowdin translations by GitHub Action 2023-09-07 18:42:45 +00:00
Vitor Pamplona
44b4b0cdb9 Makes sure only valid lnurl withdraws get highlighted. 2023-09-07 14:40:42 -04:00
Vitor Pamplona
190b7fe819 Using the correct observer for picture profile in the Top bar. 2023-09-07 12:53:19 -04:00
Vitor Pamplona
8eb89f0fe9 Adds nostr.wine and noswhere.com as two new NIP-50 relays. 2023-09-07 12:52:44 -04:00
Vitor Pamplona
3085353729 Adds SeenOn relays when successfully broadcasting a note. 2023-09-07 12:52:14 -04:00
Vitor Pamplona
1465ce112c Refines relay icon row compose 2023-09-07 11:11:10 -04:00
Vitor Pamplona
5c797858ba Merge pull request #578 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-07 10:23:12 -04:00
Crowdin Bot
8c51c4be0c New Crowdin translations by GitHub Action 2023-09-07 13:57:52 +00:00
Vitor Pamplona
4e8ecff67b Fixes the missing channel header in conversations. 2023-09-07 09:55:43 -04:00
greenart7c3
6303573dd9 support for sending and receiving nip 44 dms 2023-09-06 18:53:11 -03:00
Vitor Pamplona
83f8c51aca Merge pull request #577 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-06 10:14:04 -04:00
Crowdin Bot
e675348461 New Crowdin translations by GitHub Action 2023-09-06 14:04:55 +00:00
Vitor Pamplona
9026f5bf07 Merge pull request #574 from davotoula/main
Updated translations for cs/de
2023-09-06 10:03:30 -04: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
David Kaspar
02f18b5b35 Updated translations for cs/de 2023-09-06 13:35:45 +02:00
Vitor Pamplona
db740f4747 Upgrades the RelayPoolStatus to a Flow 2023-09-05 17:21:36 -04:00
Vitor Pamplona
6a3206b938 Avoids race condition when update EOSEs 2023-09-05 17:20:37 -04:00
Vitor Pamplona
23414b0bc6 Merge pull request #573 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-05 09:39:11 -04:00
Crowdin Bot
0234cfca30 New Crowdin translations by GitHub Action 2023-09-05 13:03:08 +00:00
Vitor Pamplona
f707714611 Merge pull request #569 from atrifat/add-arm64-armeabi-target
Add arm64-v8a and armeabi-v7a build target
2023-09-05 09:01:46 -04:00
Vitor Pamplona
7b90230c45 Merge pull request #572 from davotoula/main
Updated translations for cs/de
2023-09-05 09:01:05 -04:00
David Kaspar
d3468f3eba Added new translations for cs/de 2023-09-05 12:52:59 +02:00
Rif'at Ahdi R
fc9500b45c Update create-release.yml to suppport arm64-v8a and armeabi-v7a 2023-09-05 10:39:36 +08:00
Rif'at Ahdi R
df0b5fcd19 Add arm64-v8a and armeabi-v7a build target 2023-09-05 10:03:35 +08: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
Vitor Pamplona
40e2dba490 v0.75.14 2023-09-02 13:43:15 -04:00
Vitor Pamplona
d13e8bacec Fixes thread ordering issues with two notes with the same idHex 2023-09-02 12:46:24 -04:00
Vitor Pamplona
d3fa05a4df Remeber should be based on the Note 2023-09-02 12:28:16 -04:00
Vitor Pamplona
734dd2e119 Adds UI Feedback for Custom Zaps 2023-09-02 12:27:24 -04:00
Vitor Pamplona
a9ea9ea2ae Improves error message from LN Invoice servers. 2023-09-02 12:26:36 -04:00
Vitor Pamplona
1b521bc140 Grammar adjustment 2023-09-02 12:25:41 -04:00
Vitor Pamplona
364e3c5f5f 0.75.13 2023-09-01 14:28:40 -04:00
Vitor Pamplona
647cda3f90 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-01 14:27:52 -04:00
Vitor Pamplona
bcca4d4bcc Adds idHex to the date sorting in replies and comments to make sure the order is correct when 2 posts are placed within the same second. 2023-09-01 14:25:48 -04:00
Vitor Pamplona
0b038dca17 Merge pull request #565 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-01 14:10:07 -04:00
Crowdin Bot
ad6f63df56 New Crowdin translations by GitHub Action 2023-09-01 17:53:20 +00:00
Vitor Pamplona
b5b8b62a16 Only trigger notification of events within 5minutes from the current time (avoids annoying notifications when rebroadcasting events) 2023-09-01 13:51:23 -04:00
Vitor Pamplona
8d3066b377 Marks which relays are using compression 2023-09-01 12:36:19 -04:00
Vitor Pamplona
18933b4326 Ignores brb.io if in the relay list. It's crashing the app too much with a weird implementation of the WebSockets protocol 2023-09-01 12:36:03 -04:00
Vitor Pamplona
672841c0ed Doesn't notify if the GiftWrap's inner message came from the user. 2023-09-01 12:35:21 -04:00
Vitor Pamplona
872c7cb257 Fixes testing of thread ordering 2023-09-01 12:34:46 -04: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
Vitor Pamplona
bfae5871c8 v0.75.12 2023-08-31 19:05:12 -04:00
Vitor Pamplona
451c5d1648 Removing LiveData Redirections 2023-08-31 19:04:07 -04:00
Vitor Pamplona
3423626c60 Fixes global feed 2023-08-31 19:02:54 -04:00
Vitor Pamplona
8982f02a4a v0.75.11 2023-08-31 17:13:32 -04:00
Vitor Pamplona
f06b5fdacf New thread ordering scheme:
1. Thread author, ascending order by date.
2. My replies, descending by date.
3. My follow's replies, descending by date.
4. Everybody else, descending by date.
2023-08-31 16:41:02 -04:00
Vitor Pamplona
8ea8cf2985 Marks keep screen on to false when leaving a video player. 2023-08-31 15:23:30 -04:00
Vitor Pamplona
2771d34951 Fixes the report card when the author has been blocked by the logged in user but the list loaded before the block user list. 2023-08-31 14:53:43 -04:00
Vitor Pamplona
669b33a3e1 Reducing the height of the banner in the drawer 2023-08-31 13:43:57 -04:00
Vitor Pamplona
c934a1cdfe Reduce the space between display name and status. 2023-08-31 13:36:46 -04:00
Vitor Pamplona
0ccd41f5e8 Fixes wrong EOSE updates. 2023-08-31 13:21:08 -04:00
Vitor Pamplona
1af3bb7dfc Improve cohesiveness of the pruning system. 2023-08-31 11:48:22 -04:00
Vitor Pamplona
f0fcf1ea8a Fixes the grammar in the nip05 error messages 2023-08-31 09:04:09 -04:00
Vitor Pamplona
5325542c81 update vico 2023-08-30 19:56:37 -04:00
Vitor Pamplona
3d9675841b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-30 19:32:31 -04:00
Vitor Pamplona
0d475fba0e Merge pull request #564 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-30 17:31:45 -04:00
Crowdin Bot
05714de945 New Crowdin translations by GitHub Action 2023-08-30 21:14:24 +00:00
Vitor Pamplona
89c1b53c95 Merge pull request #563 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-30 17:13:08 -04: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
Vitor Pamplona
1c9041f12b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-30 10:12:23 -04:00
Crowdin Bot
d3de5e0a42 New Crowdin translations by GitHub Action 2023-08-30 12:50:26 +00:00
Vitor Pamplona
b1bd21533d Merge pull request #562 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-30 08:49:11 -04:00
Crowdin Bot
51f997a34a New Crowdin translations by GitHub Action 2023-08-30 12:48:43 +00:00
Vitor Pamplona
d5607faadd Requires 10 sat zaps to trigger notifications 2023-08-30 08:47:25 -04:00
Vitor Pamplona
1a9c3ccb79 Must have a valid ZapRequest to process. 2023-08-30 08:44:14 -04: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
Vitor Pamplona
053de4d06f Removes unnecessary checks since the dual LiveData structure was activated 2023-08-29 17:57:47 -04:00
Vitor Pamplona
4016458540 v0.75.10 2023-08-29 17:40:51 -04:00
Vitor Pamplona
8b98efaad7 Refines zap and report pruning. 2023-08-29 17:24:48 -04:00
Vitor Pamplona
217ed90d9a Fix the deletion of reports from authors 2023-08-29 17:14:25 -04:00
Vitor Pamplona
b82aefbd5d Merge pull request #559 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-29 16:54:41 -04:00
Crowdin Bot
63b954d38d New Crowdin translations by GitHub Action 2023-08-29 20:54:17 +00:00
Vitor Pamplona
76cdea0087 Fixing xml 2023-08-29 16:53:09 -04:00
Vitor Pamplona
da1a3a8f85 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-29 16:51:05 -04:00
Vitor Pamplona
d11e92e166 v0.75.9 2023-08-29 16:46:34 -04:00
Vitor Pamplona
61efd237c2 Fixes: https://github.com/vitorpamplona/amethyst/issues/495 and https://github.com/vitorpamplona/amethyst/issues/398 2023-08-29 16:46:27 -04:00
Vitor Pamplona
6099948605 Migrates from Toast Message to a Dialog when Zap fails in the reaction row. 2023-08-29 16:43:45 -04:00
Vitor Pamplona
45b743e7c8 Merge pull request #557 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-29 16:42:50 -04:00
Crowdin Bot
108c6cbeb4 New Crowdin translations by GitHub Action 2023-08-28 21:18:41 +00:00
Vitor Pamplona
da02891c6a Correct an xml issue from crowdin 2023-08-28 17:12:48 -04:00
Vitor Pamplona
a03e9ea946 Merge pull request #556 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-28 16:59:51 -04:00
Crowdin Bot
dd4a6f8da0 New Crowdin translations by GitHub Action 2023-08-28 20:57:58 +00:00
Vitor Pamplona
c01033cbb0 Fixes case 2023-08-28 16:56:52 -04:00
Vitor Pamplona
1fe93395c5 gotta choose one 2023-08-28 16:51:05 -04:00
Vitor Pamplona
cf960fe59d Avoid creating english files when they have not been translated yet. 2023-08-28 16:49:12 -04:00
Vitor Pamplona
2416d41131 removes filter to only run crowdin when strings are updated 2023-08-28 16:41:53 -04:00
Vitor Pamplona
0699095d77 triggers crowdin 2023-08-28 16:32:00 -04:00
Vitor Pamplona
6ed3e84d95 This might work 2023-08-28 16:31:09 -04:00
Vitor Pamplona
6c196cfeb7 triggering crowdin 2023-08-28 16:27:26 -04:00
Vitor Pamplona
987784fb89 Fixing yaml again 2023-08-28 16:26:59 -04:00
Vitor Pamplona
d60ac5d73b updates strings 2023-08-28 16:24:04 -04:00
Vitor Pamplona
c2361c034c corrects yaml 2023-08-28 16:23:51 -04:00
Vitor Pamplona
199514e7f3 trigger crowdin 2023-08-28 16:22:07 -04:00
Vitor Pamplona
a1364d19c2 correctly identifies mapping languages. 2023-08-28 16:21:47 -04:00
Vitor Pamplona
e73428b156 updates to strings.xml 2023-08-28 16:09:28 -04:00
Vitor Pamplona
a684a222cb runs github action on any updates to source or dst translations 2023-08-28 16:08:32 -04:00
Vitor Pamplona
1646cbbd68 mapping language codes to crowdin 2023-08-28 16:07:26 -04:00
Vitor Pamplona
035a48a0cd adds a type android 2023-08-28 15:55:32 -04:00
Vitor Pamplona
71be8ce239 testing update to strings.xml 2023-08-28 15:43:49 -04:00
Vitor Pamplona
1ccf9bf467 only runs with strings change 2023-08-28 15:14:47 -04:00
Vitor Pamplona
42a0cd0b1b adding the configuration file for crowdin 2023-08-28 15:12:39 -04:00
Vitor Pamplona
39547b1d11 Adds Crowin 2023-08-28 14:43:22 -04: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
Vitor Pamplona
c69969947b v0.75.8: Fixes status's cache update crash. 2023-08-27 19:33:08 -04:00
Vitor Pamplona
e1738a25a1 Do a warning log and not an error log for verification fails (it usually happens in stringified JSON inside .content) 2023-08-27 19:17:58 -04:00
Vitor Pamplona
6c54082a12 clears the index cache when changing status fields. 2023-08-27 19:16:58 -04:00
Vitor Pamplona
bc5500731d v0.75.7 2023-08-27 18:12:41 -04:00
Vitor Pamplona
0665c552aa BugFix for a crash when receiving a new notification 2023-08-27 18:11:14 -04:00
Vitor Pamplona
1ab8463578 v0.75.6 2023-08-27 17:13:29 -04:00
Vitor Pamplona
e016e68e42 removing unused sentence. 2023-08-27 17:11:49 -04:00
Vitor Pamplona
d116159ed9 v0.75.5 2023-08-27 16:49:31 -04:00
Vitor Pamplona
3c24c011e3 Avoids rebuilding the channel object in DataSource. 2023-08-27 16:45:12 -04:00
Vitor Pamplona
536bdfe43a - When All Follows, the live tab will load any event with a follow in the participant list.
- Removes prefix filters
2023-08-27 16:32:35 -04:00
Vitor Pamplona
0b41110938 Fixes formatting 2023-08-27 15:53:41 -04:00
Vitor Pamplona
da1d4018cf Fixes crash when more than 1 status 2023-08-27 15:52:35 -04:00
Vitor Pamplona
b569c67d3b Fixes Hungarian labels 2023-08-27 15:33:18 -04:00
Vitor Pamplona
50a5a68b71 Merge pull request #554 from anthony-robin/translations
Update french translations
2023-08-27 15:30:42 -04:00
Vitor Pamplona
23a4d80ddb v0.75.4 2023-08-27 12:25:53 -04:00
Vitor Pamplona
6a64ebcc5b migrates to a double layer observer structure for the memory cache 2023-08-27 12:18:11 -04:00
Vitor Pamplona
8e622bfbb1 Updates to debugging logs 2023-08-27 12:16:36 -04:00
Vitor Pamplona
d189dce7f0 Extracts chatroom from user file 2023-08-27 12:16:13 -04:00
Vitor Pamplona
43ac1bf555 Allows cleaning up on App Pause to perform separately from memory pruning functions 2023-08-27 12:10:12 -04:00
Anthony Robin
d2d26e4e57 Update french translations
Update french translations according to newly added english ones.
2023-08-27 07:52:15 +02:00
Vitor Pamplona
de1f13d64c Faster crossfade animation between screens 2023-08-26 13:04:35 -04:00
Vitor Pamplona
2be04bfbe2 Better error handling when Zaps fail 2023-08-26 13:00:53 -04:00
Vitor Pamplona
57c33ae06a Improves User filters by merging lastEOSEs into a single filter. 2023-08-26 12:59:41 -04:00
Vitor Pamplona
99e545f0b8 Adds polls to the hashtag screen 2023-08-26 11:32:37 -04:00
Vitor Pamplona
accb115a20 Making statuses filter just one with the minimum of all observing users. 2023-08-26 10:36:44 -04:00
Vitor Pamplona
9de1cec160 Merge pull request #553 from ZsZolee/main
Additional Hungarian translation
2023-08-26 09:34:36 -04:00
Zoltan
f7774700db Additional Hungarian translation 2023-08-26 13:22:27 +02:00
Vitor Pamplona
ffb81c6246 v0.75.3 2023-08-25 17:51:38 -04:00
Vitor Pamplona
b7c9b14b1f Allowing any BaseTextNote on the reply window 2023-08-25 17:47:01 -04:00
Vitor Pamplona
53b0db61ea Moves isAcceptableNote calculations to viewModel 2023-08-25 17:32:19 -04:00
Vitor Pamplona
6c09e47e4f Moves coroutine of NIP-05 verification to the viewModel 2023-08-25 17:26:47 -04:00
Vitor Pamplona
9d9ad63b4d BugFix deleting status 2023-08-25 17:15:17 -04:00
Vitor Pamplona
7ef46a2662 Continues to move away from starting co-routines directly in composables 2023-08-25 17:14:33 -04:00
Vitor Pamplona
a7d8c87c47 1. Triggers a Toast when no wallet is found to pay the invoice
2. Starts the wallet in a separate task to allow people to come back to Amethyst switching apps instead of the back button.
2023-08-25 15:18:01 -04:00
Vitor Pamplona
1bb2459277 Activates min/max values for polls 2023-08-25 15:08:10 -04:00
Vitor Pamplona
6b39562925 Allows the keyboard action to be used as commit in the status field. 2023-08-25 13:55:41 -04:00
Vitor Pamplona
3d34e1fc4e Reduces the icon to the music and adds space before the status 2023-08-25 13:41:17 -04:00
Vitor Pamplona
469e1241d5 Updates some dependencies 2023-08-25 13:28:17 -04:00
Vitor Pamplona
86c0ca975b Moves the User DataSource invalidation to the right channel 2023-08-25 13:27:57 -04:00
Vitor Pamplona
53eeea3225 Removes unused time 2023-08-25 13:26:56 -04:00
Vitor Pamplona
e9ff9d3b37 Adds a space between the status and the clickable icon 2023-08-25 13:24:47 -04:00
Vitor Pamplona
e2c011ec60 Avoids lists with the same hexId because the app was pruning events while loading them 2023-08-25 13:01:49 -04:00
Vitor Pamplona
5328740bff Improves blinking when loading a thread. 2023-08-25 13:01:08 -04:00
Vitor Pamplona
017df9845d Fixes r filter in tags 2023-08-25 12:59:21 -04:00
Vitor Pamplona
ed70658be9 Removes unecessary @ExperimentalTime tag 2023-08-25 11:52:44 -04:00
Vitor Pamplona
dc74c3014f updates cached content when note changes 2023-08-25 11:51:41 -04:00
Vitor Pamplona
53d5c5f354 BugFix for serialization filter 2023-08-25 11:49:41 -04:00
greenart7c3
df1a017eb3 refactor PackageUtils.isAmberInstalled to account.loggedinwithamber 2023-08-25 11:51:22 -03:00
Vitor Pamplona
63ab2a9fbe Moves NIP315 to NIP38 2023-08-25 10:31:51 -04: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
216 changed files with 11927 additions and 4013 deletions

View File

@@ -112,6 +112,28 @@ jobs:
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK arm64-v8a Asset
id: upload-release-asset-play-arm64-v8a-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK armeabi-v7a Asset
id: upload-release-asset-play-armeabi-v7a-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
# F-Droid APK
- name: Upload F-Droid APK Universal Asset
id: upload-release-asset-fdroid-universal-apk
@@ -146,6 +168,28 @@ jobs:
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK arm64-v8a Asset
id: upload-release-asset-fdroid-arm64-v8a-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK armeabi-v7a Asset
id: upload-release-asset-fdroid-armeabi-v7a-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
# Google Play AAB

30
.github/workflows/crowdin.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Crowdin Action
on:
push:
# paths: ["app/src/main/res/**/strings.xml"] // removes filter to allow downloads at any moment.
branches: [ main ]
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: crowdin action
uses: crowdin/github-action@v1
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: 'New Crowdin Translations'
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'main'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

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

@@ -83,7 +83,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Versioned Encrypted Payloads (NIP-44)
- [x] Expiration Support (NIP-40)
- [x] Status Event (NIP-315)
- [x] Status Event (NIP-38)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
@@ -106,7 +106,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
This is a native Android app made with Kotlin and Jetpack Compose.
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
The overall architecture consists in the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
The overall architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
@@ -172,7 +172,7 @@ For the Play build:
keytool -genkey -v -keystore <my-release-key.keystore> -alias <alias_name> -keyalg RSA -keysize 2048 -validity 10000
openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.jks.base64.txt
```
2. Create 4 Secret Key variables on your GitHub repository and fill in with the signing key information
2. Create four Secret Key variables on your GitHub repository and fill in the signing key information
- `KEY_ALIAS` <- `<alias_name>`
- `KEY_PASSWORD` <- `<your password>`
- `KEY_STORE_PASSWORD` <- `<your key store password>`
@@ -192,15 +192,15 @@ The relay also learns which public keys you are requesting, meaning your public
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs.
# DM Privacy #
While the content of direct messages (DMs) is only visible to you, and your DM nostr counterparty, everyone can see that and when you and your counterparty are DM-ing each other.
While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
# Visibility & Permanence of Your Content on nostr
## Information Visibility ##
Content that you share can be shared to other relays.
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
Information that you share publicly is visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
## Information Permanence ##
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
# Screenshots

View File

@@ -13,14 +13,14 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 281
versionName "0.75.2"
versionCode 297
versionName "0.77.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'in', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
}
buildTypes {
@@ -54,7 +54,7 @@ android {
abi {
enable true
reset()
include "x86", "x86_64"
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
universalApk true
}
}
@@ -112,7 +112,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation "net.engawapg.lib:zoomable:1.5.0"
implementation 'net.engawapg.lib:zoomable:1.5.1'
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -171,7 +171,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.2.2')
playImplementation platform('com.google.firebase:firebase-bom:32.2.3')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts

View File

@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.Event
import junit.framework.TestCase
@@ -95,7 +97,7 @@ class ThreadAssemblerTest {
val naddr = ATag(30023, "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", "1680612926599", null)
val filter = ThreadFeedFilter(naddr.toTag())
val filter = ThreadFeedFilter(Account(KeyPair()), naddr.toTag())
val calculatedFeed = filter.feed()
val expecteedOrder = listOf(
@@ -126,14 +128,14 @@ class ThreadAssemblerTest {
"6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4",
"6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191",
"e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3",
"45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7",
"7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3",
"674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d",
"d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9",
"00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031",
"87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8",
"512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e",
"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883",
"7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3",
"45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7",
"d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5",
"8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6",
"7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26"

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

@@ -35,7 +35,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,6 +75,7 @@ private object PrefKeys {
const val THEME = "theme"
const val PREFERRED_LANGUAGE = "preferred_Language"
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -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? {
@@ -363,6 +372,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)
@@ -431,7 +441,8 @@ object LocalPreferences {
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
settings = settings
settings = settings,
loginWithExternalSigner = loginWithExternalSigner
)
return a

View File

@@ -92,9 +92,7 @@ class MultiPlayerPlaybackManager(
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
STATE_IDLE -> {
if (player.currentPosition > 5 * 60) { // 5 seconds
cachedPositions.add(uri, player.currentPosition)
}
cachedPositions.add(uri, player.currentPosition)
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
@@ -104,9 +102,7 @@ class MultiPlayerPlaybackManager(
}
}
else -> {
if (player.currentPosition > 5 * 60) { // 5 seconds
cachedPositions.add(uri, player.currentPosition)
}
cachedPositions.add(uri, player.currentPosition)
}
}
}

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,9 +121,14 @@ object ServiceManager {
NostrVideoDataSource.stop()
Client.disconnect()
isStarted = false
}
fun cleanUp() {
fun cleanObservers() {
LocalCache.cleanObservers()
}
fun trimMemory() {
LocalCache.cleanObservers()
val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet()

File diff suppressed because it is too large Load Diff

View File

@@ -164,20 +164,12 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
override fun onActive() {
super.onActive()
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.add(channel.idHex)
} else {
NostrSingleChannelDataSource.add(channel.idHex)
}
NostrSingleChannelDataSource.add(channel)
}
override fun onInactive() {
super.onInactive()
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.remove(channel.idHex)
} else {
NostrSingleChannelDataSource.remove(channel.idHex)
}
NostrSingleChannelDataSource.remove(channel)
}
}

View File

@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class Chatroom() {
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
subject = newSubject
subjectCreatedAt = msg.createdAt()
}
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
}
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
return toRemove
}
}

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,10 +13,10 @@ 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.*
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
@@ -54,6 +56,10 @@ object LocalCache {
// checkNotInMainThread()
return users[key] ?: run {
require(isValidHex(key = key)) {
"$key is not a valid hex"
}
val newObject = User(key)
users.putIfAbsent(key, newObject) ?: newObject
}
@@ -99,6 +105,10 @@ object LocalCache {
checkNotInMainThread()
return notes.get(idHex) ?: run {
require(isValidHex(idHex)) {
"$idHex is not a valid hex"
}
val newObject = Note(idHex)
notes.putIfAbsent(idHex, newObject) ?: newObject
}
@@ -199,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()}")
@@ -361,7 +379,7 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
author.liveSet?.statuses?.invalidateData()
author.liveSet?.innerStatuses?.invalidateData()
refreshObservers(note)
}
@@ -651,7 +669,7 @@ object LocalCache {
mentions.forEach {
// doesn't add to reports, but triggers recounts
it.liveSet?.reports?.invalidateData()
it.liveSet?.innerReports?.invalidateData()
}
}
@@ -808,23 +826,23 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) {
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
return
}
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
(
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
(zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
)
note.loadEvent(event, author, repliesTo)
if (zapRequest == null) {
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
return
}
// Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
repliesTo.forEach {
@@ -1184,13 +1202,7 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// Counts the replies
it.replyTo?.forEach { parent ->
parent.removeReply(it)
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1209,12 +1221,7 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
// Counts the replies
it.replyTo?.forEach { parent ->
parent.removeReply(it)
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1241,21 +1248,12 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
val newerVersion = addressables[(it.event as? AddressableEvent)?.address()?.toTag()]
if (newerVersion != null) {
it.moveAllReferencesTo(newerVersion)
}
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.clearEOSE() // allows reloading of these events
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1275,6 +1273,7 @@ object LocalCache {
it.value.event is ReactionEvent || it.value.event is LnZapEvent || it.value.event is LnZapRequestEvent ||
it.value.event is ReportEvent || it.value.event is GenericRepostEvent
) &&
it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
it.value.liveSet?.isInUse() != true && // don't delete if observing.
it.value.author?.pubkeyHex !in accounts && // don't delete if it is the logged in account
it.value.event?.isTaggedUsers(accounts) != true // don't delete if it's a notification to the logged in user
@@ -1283,32 +1282,91 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.removeReport(it)
masterNote.clearEOSE() // allows reloading of these events
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
toBeRemoved.forEach {
it.replyTo?.forEach { masterNote ->
masterNote.clearEOSE() // allows reloading of these events
}
}
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
}
private fun removeFromCache(note: Note) {
note.replyTo?.forEach { masterNote ->
masterNote.removeReply(note)
masterNote.removeBoost(note)
masterNote.removeReaction(note)
masterNote.removeZap(note)
masterNote.removeReport(note)
masterNote.clearEOSE() // allows reloading of these events if needed
}
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull {
val author = getUserIfExists(it.key)
author?.removeReport(note)
author?.clearEOSE()
}
}
notes.remove(note.idHex)
}
fun removeAuthorLinkTo(note: Note) {
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull {
val author = getUserIfExists(it.key)
author?.removeReport(note)
author?.clearEOSE()
}
}
}
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note ->
removeFromCache(note)
}
}
fun pruneExpiredEvents() {
checkNotInMainThread()
val now = TimeUtils.now()
val toBeRemoved = notes.filter {
it.value.event?.isExpired() == true
}.values
@@ -1316,17 +1374,7 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.removeReport(it)
masterNote.clearEOSE() // allows reloading of these events
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1353,17 +1401,7 @@ object LocalCache {
}.flatten()
toBeRemoved.forEach {
// Counts the replies
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.removeReport(it)
}
notes.remove(it.idHex)
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1372,21 +1410,6 @@ object LocalCache {
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note ->
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull { getUserIfExists(it.key)?.removeZap(note) }
}
notes.remove(note.idHex)
}
}
fun pruneContactLists(loggedIn: Set<HexKey>) {
checkNotInMainThread()
@@ -1479,9 +1502,10 @@ object LocalCache {
is LiveActivitiesChatMessageEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
// must have a valid request
verifyAndConsume(it, relay)
consume(event)
}
consume(event)
}
is LnZapRequestEvent -> consume(event)
is LnZapPaymentRequestEvent -> consume(event)

View File

@@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
@@ -22,6 +22,7 @@ import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal
import java.time.Instant
@@ -126,7 +127,7 @@ open class Note(val idHex: String) {
this.author = author
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
}
@@ -135,26 +136,60 @@ open class Note(val idHex: String) {
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
eventsToConsider: Set<Note>,
cachedSignatures: MutableMap<Note, String>
): String {
eventsToConsider: Set<HexKey>,
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long
): LevelSignature {
val replyTo = replyTo
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
return LevelSignature(
signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
createdAt = createdAt(),
author = author
)
}
val mySignature = (
val parent = (
replyTo
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
.filter { it.idHex in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) }
cachedSignatures[it] ?: it.replyLevelSignature(
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now
).apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.length }?.removeSuffix(";") ?: ""
) + "/" + formattedDateTime(createdAt() ?: 0) + ";"
.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder = if (parent?.author == author && createdAt() != null) {
// author of the thread first, in **ascending** order
"9" + formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) + idHex.substring(0, 8)
} else if (author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
} else if (author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
}
val mySignature = LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = createdAt(),
author = author
)
cachedSignatures[this] = mySignature
return mySignature
@@ -174,21 +209,21 @@ open class Note(val idHex: String) {
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
liveSet?.replies?.invalidateData()
liveSet?.innerReplies?.invalidateData()
}
}
fun removeReply(note: Note) {
if (note in replies) {
replies = replies - note
liveSet?.replies?.invalidateData()
liveSet?.innerReplies?.invalidateData()
}
}
fun removeBoost(note: Note) {
if (note in boosts) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
}
}
@@ -211,11 +246,11 @@ open class Note(val idHex: String) {
relays = listOf<String>()
lastReactionsDownloadTime = emptyMap()
liveSet?.replies?.invalidateData()
liveSet?.reactions?.invalidateData()
liveSet?.boosts?.invalidateData()
liveSet?.reports?.invalidateData()
liveSet?.zaps?.invalidateData()
liveSet?.innerReplies?.invalidateData()
liveSet?.innerReactions?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
liveSet?.innerReports?.invalidateData()
liveSet?.innerZaps?.invalidateData()
return toBeRemoved
}
@@ -234,7 +269,7 @@ open class Note(val idHex: String) {
reactions = reactions + Pair(reaction, newList)
}
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
}
}
}
@@ -246,7 +281,7 @@ open class Note(val idHex: String) {
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
}
@@ -254,28 +289,28 @@ open class Note(val idHex: String) {
fun removeZap(note: Note) {
if (zaps[note] != null) {
zaps = zaps.minus(note)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zaps.containsValue(note)) {
zaps = zaps.filterValues { it != note }
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) {
zapPayments = zapPayments.minus(note)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
liveSet?.boosts?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
}
}
@@ -297,12 +332,12 @@ open class Note(val idHex: String) {
if (zapRequest !in zaps.keys) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
} else if (zaps[zapRequest] == null) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
}
@@ -325,12 +360,12 @@ open class Note(val idHex: String) {
if (zapPaymentRequest !in zapPayments.keys) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
} else if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
}
@@ -341,10 +376,10 @@ open class Note(val idHex: String) {
if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
}
}
@@ -353,17 +388,17 @@ open class Note(val idHex: String) {
if (author !in reports.keys) {
reports = reports + Pair(author, listOf(note))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
fun addRelay(relay: Relay) {
if (relay.url !in relays) {
relays = relays + relay.url
liveSet?.relays?.invalidateData()
liveSet?.innerRelays?.invalidateData()
}
}
@@ -463,10 +498,6 @@ open class Note(val idHex: String) {
}.flatten()
}
fun countReactions(): Int {
return reactions.values.sumOf { it.size }
}
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
@@ -657,36 +688,58 @@ open class Note(val idHex: String) {
@Stable
class NoteLiveSet(u: Note) {
// Observers line up here.
val metadata: NoteLiveData = NoteLiveData(u)
val innerMetadata = NoteBundledRefresherLiveData(u)
val innerReactions = NoteBundledRefresherLiveData(u)
val innerBoosts = NoteBundledRefresherLiveData(u)
val innerReplies = NoteBundledRefresherLiveData(u)
val innerReports = NoteBundledRefresherLiveData(u)
val innerRelays = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val authorChanges = metadata.map {
val metadata = innerMetadata.map { it }
val reactions = innerReactions.map { it }
val boosts = innerBoosts.map { it }
val replies = innerReplies.map { it }
val reports = innerReports.map { it }
val relays = innerRelays.map { it }
val zaps = innerZaps.map { it }
val authorChanges = innerMetadata.map {
it.note.author
}
val hasEvent = metadata.map {
val hasEvent = innerMetadata.map {
it.note.event != null
}.distinctUntilChanged()
val reactions: NoteLiveData = NoteLiveData(u)
val boosts: NoteLiveData = NoteLiveData(u)
val replies: NoteLiveData = NoteLiveData(u)
val reports: NoteLiveData = NoteLiveData(u)
val relays: NoteLiveData = NoteLiveData(u)
val zaps: NoteLiveData = NoteLiveData(u)
val hasReactions = zaps.combineWith(boosts, reactions) { zapState, boostState, reactionState ->
val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() ?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged()
val replyCount = replies.map {
val replyCount = innerReplies.map {
it.note.replies.size
}.distinctUntilChanged()
val boostCount = boosts.map {
val reactionCount = innerReactions.map {
it.note.reactions.values.sumOf { it.size }
}.distinctUntilChanged()
val boostCount = innerBoosts.map {
it.note.boosts.size
}.distinctUntilChanged()
val boostList = innerBoosts.map {
it.note.boosts.toImmutableList()
}.distinctUntilChanged()
val relayInfo = innerRelays.map {
it.note.relays.map {
RelayBriefInfo(it)
}.toImmutableList()
}
fun isInUse(): Boolean {
return metadata.hasObservers() ||
reactions.hasObservers() ||
@@ -694,21 +747,28 @@ class NoteLiveSet(u: Note) {
replies.hasObservers() ||
reports.hasObservers() ||
relays.hasObservers() ||
zaps.hasObservers()
zaps.hasObservers() ||
authorChanges.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers() ||
boostList.hasObservers()
}
fun destroy() {
metadata.destroy()
reactions.destroy()
boosts.destroy()
replies.destroy()
reports.destroy()
relays.destroy()
zaps.destroy()
innerMetadata.destroy()
innerReactions.destroy()
innerBoosts.destroy()
innerReplies.destroy()
innerReports.destroy()
innerRelays.destroy()
innerZaps.destroy()
}
}
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -717,19 +777,26 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
}
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(NoteState(note))
}
postValue(NoteState(note))
}
}
fun <Y> map(
transform: (NoteState) -> Y
): NoteLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = NoteLoadingLiveData(note, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
if (note is AddressableNote) {
@@ -751,3 +818,10 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
@Immutable
class NoteState(val note: Note)
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
)

View File

@@ -14,4 +14,6 @@ data class RelaySetupInfo(
val spamCount: Int = 0,
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false
)
) {
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
}

View File

@@ -3,17 +3,17 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
@@ -23,13 +23,9 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal
import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
@Stable
class User(val pubkeyHex: String) {
@@ -101,7 +97,11 @@ class User(val pubkeyHex: String) {
if (event.id == latestBookmarkList?.id) return
latestBookmarkList = event
liveSet?.bookmarks?.invalidateData()
liveSet?.innerBookmarks?.invalidateData()
}
fun clearEOSE() {
latestEOSEs = emptyMap()
}
fun updateContactList(event: ContactListEvent) {
@@ -111,18 +111,18 @@ class User(val pubkeyHex: String) {
latestContactList = event
// Update following of the current user
liveSet?.follows?.invalidateData()
liveSet?.innerFollows?.invalidateData()
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
}
liveSet?.relays?.invalidateData()
liveSet?.innerRelays?.invalidateData()
}
fun addReport(note: Note) {
@@ -130,10 +130,10 @@ class User(val pubkeyHex: String) {
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
@@ -143,7 +143,7 @@ class User(val pubkeyHex: String) {
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
}
@@ -151,20 +151,20 @@ class User(val pubkeyHex: String) {
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zapRequestOrZapEvent in zaps.keys) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zapRequestOrZapEvent in zaps.values) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
@@ -218,7 +218,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -226,7 +226,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -240,7 +240,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -255,7 +255,7 @@ class User(val pubkeyHex: String) {
here.counter++
}
liveSet?.relayInfo?.invalidateData()
liveSet?.innerRelayInfo?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
@@ -264,23 +264,15 @@ class User(val pubkeyHex: String) {
info?.updatedMetadataAt = latestMetadata.createdAt
info?.tags = latestMetadata.tags.toImmutableListOfLists()
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
try {
val url = String(Bech32.decodeBytes(newUserInfo.lud06!!, false).second)
val matcher = lnurlpPattern.matcher(url)
while (matcher.find()) {
val domain = matcher.group(2)
val username = matcher.group(3)
info?.lud16 = "$username@$domain"
if (newUserInfo.lud16.isNullOrBlank()) {
info?.lud06?.let {
if (it.lowercase().startsWith("lnurl")) {
info?.lud16 = Lud06().toLud16(it)
}
} catch (t: Throwable) {
// Doesn't create errors.
}
}
liveSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
fun isFollowing(user: User): Boolean {
@@ -382,54 +374,70 @@ class User(val pubkeyHex: String) {
@Stable
class UserLiveSet(u: User) {
// UI Observers line up here.
val follows: UserLiveData = UserLiveData(u)
val followers: UserLiveData = UserLiveData(u)
val reports: UserLiveData = UserLiveData(u)
val messages: UserLiveData = UserLiveData(u)
val relays: UserLiveData = UserLiveData(u)
val relayInfo: UserLiveData = UserLiveData(u)
val metadata: UserLiveData = UserLiveData(u)
val zaps: UserLiveData = UserLiveData(u)
val bookmarks: UserLiveData = UserLiveData(u)
val statuses: UserLiveData = UserLiveData(u)
val innerMetadata = UserBundledRefresherLiveData(u)
val profilePictureChanges = metadata.map {
// UI Observers line up here.
val innerFollows = UserBundledRefresherLiveData(u)
val innerFollowers = UserBundledRefresherLiveData(u)
val innerReports = UserBundledRefresherLiveData(u)
val innerMessages = UserBundledRefresherLiveData(u)
val innerRelays = UserBundledRefresherLiveData(u)
val innerRelayInfo = UserBundledRefresherLiveData(u)
val innerZaps = UserBundledRefresherLiveData(u)
val innerBookmarks = UserBundledRefresherLiveData(u)
val innerStatuses = UserBundledRefresherLiveData(u)
// UI Observers line up here.
val metadata = innerMetadata.map { it }
val follows = innerFollows.map { it }
val followers = innerFollowers.map { it }
val reports = innerReports.map { it }
val messages = innerMessages.map { it }
val relays = innerRelays.map { it }
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map { it }
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map {
it.user.profilePicture()
}.distinctUntilChanged()
val nip05Changes = metadata.map {
val nip05Changes = innerMetadata.map {
it.user.nip05()
}.distinctUntilChanged()
val userMetadataInfo = metadata.map {
val userMetadataInfo = innerMetadata.map {
it.user.info
}.distinctUntilChanged()
fun isInUse(): Boolean {
return follows.hasObservers() ||
return metadata.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
messages.hasObservers() ||
relays.hasObservers() ||
relayInfo.hasObservers() ||
metadata.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers() ||
statuses.hasObservers() ||
bookmarks.hasObservers()
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers()
}
fun destroy() {
follows.destroy()
followers.destroy()
reports.destroy()
messages.destroy()
relays.destroy()
relayInfo.destroy()
metadata.destroy()
zaps.destroy()
bookmarks.destroy()
statuses.destroy()
innerMetadata.destroy()
innerFollows.destroy()
innerFollowers.destroy()
innerReports.destroy()
innerMessages.destroy()
innerRelays.destroy()
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerStatuses.destroy()
}
}
@@ -440,64 +448,7 @@ data class RelayInfo(
var counter: Long
)
@Stable
class Chatroom() {
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
subject = newSubject
subjectCreatedAt = msg.createdAt()
}
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
}
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
return toRemove
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -506,18 +457,26 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
}
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(UserState(user))
}
postValue(UserState(user))
}
}
fun <Y> map(
transform: (UserState) -> Y
): UserLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = UserLoadingLiveData(user, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user)

View File

@@ -0,0 +1,307 @@
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)
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

@@ -47,13 +47,13 @@ class Nip05Verifier() {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
onError("Could not resolve $url. Check if the server is up and if the address $nip05 is correct")
e.printStackTrace()
}
})

View File

@@ -0,0 +1,103 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.amethyst.model.RelayInformation
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
object Nip11CachedRetriever {
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
val retriever = Nip11Retriever()
suspend fun loadRelayInfo(
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
) {
val url = retriever.cleanUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
if (doc != null) {
onInfo(doc)
} else {
Nip11Retriever().loadRelayInfo(
url,
dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, it)
onInfo(it)
},
onError
)
}
}
}
class Nip11Retriever {
enum class ErrorCode {
FAIL_TO_ASSEMBLE_URL,
FAIL_TO_REACH_SERVER,
FAIL_TO_PARSE_RESULT,
FAIL_WITH_HTTP_STATUS
}
fun cleanUrl(dirtyUrl: String): String {
return if (dirtyUrl.contains("://")) {
dirtyUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
}
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit
) {
try {
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
}
override fun onFailure(call: Call, e: IOException) {
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
}
}
)
} catch (e: Exception) {
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
}
}
}

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

@@ -14,6 +14,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.Error
abstract class NostrDataSource(val debugName: String) {
@@ -23,6 +24,7 @@ abstract class NostrDataSource(val debugName: String) {
data class Counter(var counter: Int)
private var eventCounter = mapOf<String, Counter>()
var changingFilters = AtomicBoolean()
fun printCounter() {
eventCounter.forEach {
@@ -49,7 +51,7 @@ abstract class NostrDataSource(val debugName: String) {
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
override fun onRelayStateChange(type: Relay.Type, relay: Relay, subscriptionId: String?) {
// Log.d("RELAY", "Relay ${relay.url} ${when (type) {
// Relay.Type.CONNECT -> "connected."
// Relay.Type.DISCONNECT -> "disconnected."
@@ -57,13 +59,19 @@ abstract class NostrDataSource(val debugName: String) {
// Relay.Type.EOSE -> "sent all events it had stored."
// }}")
if (type == Relay.Type.EOSE && channel != null) {
if (type == Relay.Type.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
// updates a per subscripton since date
subscriptions[channel]?.updateEOSE(TimeUtils.now(), relay.url)
subscriptions[subscriptionId]?.updateEOSE(
TimeUtils.fiveMinutesAgo(), // in case people's clock is slighly off.
relay.url
)
}
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
if (success) {
markAsSeenOnRelay(eventId, relay)
}
}
override fun onAuth(relay: Relay, challenge: String) {
@@ -111,12 +119,14 @@ abstract class NostrDataSource(val debugName: String) {
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
scope.launch(Dispatchers.IO) {
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
}
@@ -134,6 +144,8 @@ abstract class NostrDataSource(val debugName: String) {
// saves the current content to only update if it changes
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
changingFilters.getAndSet(true)
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
@@ -165,12 +177,18 @@ abstract class NostrDataSource(val debugName: String) {
}
}
}
changingFilters.getAndSet(false)
}
open fun consume(event: Event, relay: Relay) {
LocalCache.verifyAndConsume(event, relay)
}
open fun markAsSeenOnRelay(eventId: String, relay: Relay) {
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
}

View File

@@ -18,53 +18,66 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
val latestEOSEs = EOSEAccount()
fun createLiveStreamFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val followKeys = follows?.map {
it.substring(0, 8)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = follows,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
),
follows?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.kind),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
)
}
fun createPublicChatFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val followChats = account.selectedChatsFollowList().toList()
val followKeys = follows?.map {
it.substring(0, 8)
}
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
return listOf(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = follows,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
),
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 8)
}
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
authors = follows,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
@@ -197,16 +210,16 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = listOfNotNull(
createLiveStreamFilter(),
createPublicChatFilter(),
createCommunitiesFilter(),
createLiveStreamTagsFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter(),
createLiveStreamGeohashesFilter()
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter()
)
).ifEmpty { null }
}
}

View File

@@ -55,12 +55,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
val followKeys = follows?.map {
it.substring(0, 8)
}
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 8))
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
return TypedFilter(
types = setOf(FeedType.FOLLOWS),

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

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -11,10 +11,10 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()
private var channelsToWatch = setOf<Channel>()
private fun createRepliesAndReactionsFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.map { it }
private fun createMetadataChangeFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex }
if (reactionsToWatch.isEmpty()) {
return null
@@ -32,7 +32,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -53,7 +52,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null }
@@ -81,7 +79,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val reactions = createMetadataChangeFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
@@ -90,13 +88,17 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
).ifEmpty { null }
}
fun add(eventId: String) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
fun add(eventId: Channel) {
if (eventId !in channelsToWatch) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: String) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
fun remove(eventId: Channel) {
if (eventId in channelsToWatch) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
}
}
}

View File

@@ -134,6 +134,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
val singleEventChannel = requestNewChannel { time, relayUrl ->
// Ignores EOSE if it is in the middle of a filter change.
if (changingFilters.get()) return@requestNewChannel
checkNotInMainThread()
eventsToWatch.forEach {

View File

@@ -12,49 +12,60 @@ import com.vitorpamplona.quartz.events.StatusEvent
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()
fun createUserFilter(): List<TypedFilter>? {
fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
authors = firstTimers,
limit = 10 * firstTimers.size
)
)
}
)
}
fun createUserStatusFilter(): List<TypedFilter>? {
fun createUserMetadataFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = listOf(it.pubkeyHex),
since = it.latestEOSEs
)
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
}
)
}
fun createUserReportFilter(): List<TypedFilter>? {
fun createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),
since = it.latestEOSEs
)
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
}
)
}
fun createUserReportFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }),
since = minLatestEOSEs
)
)
}
val userChannel = requestNewChannel() { time, relayUrl ->
@@ -66,17 +77,40 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
eose.time = time
}
}
}
val userChannelFirstTimers = requestNewChannel() { time, relayUrl ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
val userChannelOnce = requestNewChannel()
override fun updateChannelFilters() {
userChannel.typedFilters = listOfNotNull(createUserReportFilter(), createUserStatusFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
val neverGottenAnEOSE = mutableSetOf<String>()
usersToWatch.forEach {
if (it.latestEOSEs.isEmpty()) { // first time
neverGottenAnEOSE.add(it.pubkeyHex)
} else {
it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
}
userChannel.typedFilters = listOfNotNull(
createUserMetadataFilter(minLatestEOSEs),
createUserStatusFilter(minLatestEOSEs),
createUserReportFilter(minLatestEOSEs)
).ifEmpty { null }
userChannelFirstTimers.typedFilters = listOfNotNull(
createUserMetadataFilter()
).flatten().ifEmpty { null }
}
fun add(user: User) {

View File

@@ -36,8 +36,10 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
}
fun loadThread(noteId: String?) {
eventToWatch = noteId
if (eventToWatch != noteId) {
eventToWatch = noteId
invalidateFilters()
invalidateFilters()
}
}
}

View File

@@ -14,16 +14,12 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
val latestEOSEs = EOSEAccount()
fun createContextualFilter(): TypedFilter? {
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
authors = follows,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList

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

@@ -0,0 +1,207 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.round
class ZapPaymentHandler(val account: Account) {
@Immutable
data class Payable(
val info: ZapSplitSetup,
val user: User?,
val amountMilliSats: Long,
val invoice: String
)
suspend fun zap(
note: Note,
amountMilliSats: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
zapType: LnZapEvent.ZapType
) = withContext(Dispatchers.IO) {
val zapSplitSetup = note.event?.zapSplitSetup()
val zapsToSend = if (!zapSplitSetup.isNullOrEmpty()) {
zapSplitSetup
} else {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
return@withContext
}
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
val totalWeight = zapsToSend.sumOf { it.weight }
val invoicesToPayOnIntent = mutableListOf<Payable>()
zapsToSend.forEachIndexed { index, value ->
val outerProgressMin = index / zapsToSend.size.toFloat()
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
val zapValue =
round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
if (value.isLnAddress) {
innerZap(
lud16 = value.lnAddressOrPubKeyHex,
note = note,
amount = zapValue,
pollOption = pollOption,
message = message,
context = context,
onError = onError,
onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
},
zapType = zapType,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = null,
amountMilliSats = zapValue,
invoice = it
)
)
}
)
} else {
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
val lud16 = user?.info?.lnAddress()
if (lud16 != null) {
innerZap(
lud16 = lud16,
note = note,
amount = zapValue,
pollOption = pollOption,
message = message,
context = context,
onError = onError,
onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
},
zapType = zapType,
overrideUser = user,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = user,
amountMilliSats = zapValue,
invoice = it
)
)
}
)
} else {
onError(
context.getString(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex
)
)
}
}
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f)
} else {
launch(Dispatchers.IO) {
// Awaits for the event to come back to LocalCache.
delay(5000)
onProgress(1f)
}
}
}
private suspend fun innerZap(
lud16: String,
note: Note,
amount: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayInvoiceThroughIntent: (String) -> Unit,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null
) {
var zapRequestJson = ""
if (zapType != LnZapEvent.ZapType.NONZAP) {
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser)
if (zapRequest != null) {
zapRequestJson = zapRequest.toJson()
}
}
onProgress(0.10f)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
response.error?.message
?: response.error?.code?.toString()
?: "Error parsing error message"
)
} else {
onProgress(1f)
}
}
)
onProgress(0.8f)
} else {
try {
onPayInvoiceThroughIntent(it)
} catch (e: Exception) {
onError(context.getString(R.string.lightning_wallets_not_found2))
}
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress
)
}
}

View File

@@ -4,16 +4,14 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toLnUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
class LightningAddressResolver() {
@@ -27,11 +25,7 @@ class LightningAddressResolver() {
}
if (lnaddress.lowercase().startsWith("lnurl")) {
return try {
String(Bech32.decodeBytes(lnaddress, false).second)
} catch (e: Exception) {
null
}
return Lud06().toLnUrlp(lnaddress)
}
return null
@@ -57,16 +51,16 @@ class LightningAddressResolver() {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
onError("The receiver's lightning service at $url is not available. It was calculated from the lightning address \"${lnaddress}\". Error: ${it.code}. Check if the server is up and if the lightning address is correct")
}
}
} catch (e: Exception) {
e.printStackTrace()
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
onError("Could not resolve $url. Check if the server is up and if the lightning address $lnaddress is correct")
}
}
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
@@ -82,22 +76,13 @@ class LightningAddressResolver() {
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(response.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
client.newCall(request).execute().use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
}
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
@@ -115,7 +100,7 @@ class LightningAddressResolver() {
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
@@ -159,14 +144,22 @@ class LightningAddressResolver() {
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
// Forces LN Invoice amount to be the requested amount.
val expectedAmountInSats = BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
if (invoiceAmount.toLong() == expectedAmountInSats) {
onProgress(0.7f)
onSuccess(pr)
} else {
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
onProgress(0.0f)
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from $lnaddress. It should have been $expectedAmountInSats")
}
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
} ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason ->
onProgress(0.0f)
onError("Unable to create a lightning invoice before sending the zap. The receiver's lightning wallet sent the following error: $reason")
} ?: run {
onProgress(0.0f)
onError("nable to create a lightning invoice before sending the zap. Element pr not found in the resulting JSON.")
}
},
onError = onError
)

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
@@ -19,43 +21,127 @@ import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentSetOf
import java.math.BigDecimal
class EventNotificationConsumer(private val applicationContext: Context) {
suspend fun consume(event: GiftWrapEvent) {
if (!LocalCache.justVerify(event)) return
if (!notificationManager().areNotificationsEnabled()) return
fun consume(event: Event) {
if (LocalCache.notes[event.id] == null) {
if (LocalCache.justVerify(event)) {
LocalCache.justConsume(event, null)
// PushNotification Wraps don't include a receiver.
// Test with all logged in accounts
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && (acc.keyPair.privKey != null || acc.loginWithExternalSigner)) {
consumeIfMatchesAccount(event, acc)
}
}
}
val manager = notificationManager()
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
is GiftWrapEvent -> unwrapAndNotify(event)
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
val key = account.keyPair.privKey
if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
pushWrappedEvent.content,
pushWrappedEvent.pubKey,
pushWrappedEvent.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id] ?: ""
}
pushWrappedEvent.unwrap(cached)?.let { notificationEvent ->
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
} else if (key != null) {
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
}
}
fun unwrapAndConsume(event: Event, account: Account): Event? {
private fun unwrapAndConsume(event: Event, account: Account): Event? {
if (!LocalCache.justVerify(event)) return null
return when (event) {
is GiftWrapEvent -> {
val key = account.keyPair.privKey ?: return null
event.cachedGift(key)?.let {
unwrapAndConsume(it, account)
val key = account.keyPair.privKey
if (key != null) {
event.cachedGift(key)?.let {
unwrapAndConsume(it, account)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGift(account.keyPair.pubKey, cached)?.let {
unwrapAndConsume(it, account)
}
} else {
null
}
}
is SealedGossipEvent -> {
val key = account.keyPair.privKey ?: return null
event.cachedGossip(key)?.let {
// this is not verifiable
LocalCache.justConsume(it, null)
it
val key = account.keyPair.privKey
if (key != null) {
event.cachedGossip(key)?.let {
// this is not verifiable
LocalCache.justConsume(it, null)
it
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
LocalCache.justConsume(it, null)
it
}
} else {
null
}
}
else -> {
@@ -65,106 +151,106 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
private fun unwrapAndNotify(giftWrap: GiftWrapEvent) {
val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return
private fun notify(event: ChatMessageEvent, acc: Account) {
if (event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
) { // from the user
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
val chatNote = LocalCache.notes[event.id] ?: return
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
if (acc != null && acc.userProfile().pubkeyHex == giftWrap.recipientPubKey()) {
val chatEvent = unwrapAndConsume(giftWrap, account = acc)
val followingKeySet = acc.followingKeySet()
if (chatEvent is ChatMessageEvent && acc.keyPair.privKey != null) {
val chatNote = LocalCache.notes[chatEvent.id] ?: return
val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.toHexKey())
val isKnownRoom = (
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
) && !acc.isAllHidden(chatRoom.users)
val followingKeySet = acc.followingKeySet()
val isKnownRoom = (
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
) && !acc.isAllHidden(chatRoom.users)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent()
notificationManager().sendDMNotification(chatEvent.id, content, user, userPicture, noteUri, applicationContext)
}
}
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent()
notificationManager().sendDMNotification(
event.id,
content,
user,
userPicture,
noteUri,
applicationContext
)
}
}
}
private fun notify(event: PrivateDmEvent) {
private fun notify(event: PrivateDmEvent, acc: Account) {
val note = LocalCache.notes[event.id] ?: return
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
(
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}.toSet()
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
(
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}.toSet()
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
}
private fun notify(event: LnZapEvent) {
private fun notify(event: LnZapEvent, acc: Account) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let {
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
Pair(author, decryptedContent.content)
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
Pair(noteZapRequest.author, noteZapRequest.event?.content())
} else {
Pair(noteZapRequest.author, null)
}
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
Pair(author, decryptedContent.content)
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
Pair(noteZapRequest.author, noteZapRequest.event?.content())
} else {
Pair(noteZapRequest.author, null)
}
val zappedContent =
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
val user = senderInfo?.first?.toBestDisplayName() ?: ""
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo?.second?.ifBlank { null }?.let {
title += " ($it)"
}
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
zappedContent?.let {
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
}
val zappedContent =
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
val user = senderInfo?.first?.toBestDisplayName() ?: ""
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo?.second?.ifBlank { null }?.let {
title += " ($it)"
}
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
zappedContent?.let {
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
}
}

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

@@ -16,9 +16,7 @@ object Constants {
}
val defaultRelays = arrayOf(
// Free relays for DMs and Follows
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
// Free relays for only DMs and Follows due to the amount of spam
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
// Chats
@@ -40,6 +38,7 @@ object Constants {
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
RelaySetupInfo("wss://relay.snort.social", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
@@ -51,8 +50,15 @@ object Constants {
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
// Supporting NIP-50
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
)
val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
val forcedRelayForSearch = arrayOf(
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
)
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
}

View File

@@ -60,7 +60,7 @@ class JsonFilter(
} else {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
put(sincePairs.key, "${sincePairs.value}")
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
}
put("since", jsonObjectSince)
}

View File

@@ -47,6 +47,7 @@ class Relay(
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
private var isReady: Boolean = false
private var usingCompression: Boolean = false
var eventDownloadCounterInBytes = 0
var eventUploadCounterInBytes = 0
@@ -85,6 +86,9 @@ class Relay(
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
// BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return
// If there is a connection, don't wait.
if (connectingBlock.getAndSet(true)) {
return
@@ -115,7 +119,10 @@ class Relay(
override fun onOpen(webSocket: WebSocket, response: Response) {
checkNotInMainThread()
markConnectionAsReady(response.receivedResponseAtMillis - response.sentRequestAtMillis)
markConnectionAsReady(
pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis,
usingCompression = response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false
)
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
onConnected(this@Relay)
@@ -175,15 +182,17 @@ class Relay(
}
}
fun markConnectionAsReady(pingInMs: Long) {
fun markConnectionAsReady(pingInMs: Long, usingCompression: Boolean) {
this.afterEOSE = false
this.isReady = true
this.pingInMs = pingInMs
this.usingCompression = usingCompression
}
fun markConnectionAsClosed() {
this.socket = null
this.isReady = false
this.usingCompression = false
this.afterEOSE = false
this.closingTimeInSeconds = TimeUtils.now()
}
@@ -239,6 +248,7 @@ class Relay(
socket?.close(1000, "Normal close")
socket = null
isReady = false
usingCompression = false
afterEOSE = false
}

View File

@@ -1,9 +1,13 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
@@ -12,6 +16,11 @@ object RelayPool : Relay.Listener {
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
// Backing property to avoid flow emissions from other classes
private var _lastStatus = RelayPoolStatus(0, 0)
private val _statusFlow = MutableSharedFlow<RelayPoolStatus>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val statusFlow: SharedFlow<RelayPoolStatus> = _statusFlow.asSharedFlow()
fun availableRelays(): Int {
return relays.size
}
@@ -76,11 +85,13 @@ object RelayPool : Relay.Listener {
fun addRelay(relay: Relay) {
relay.register(this)
relays += relay
updateStatus()
}
fun removeRelay(relay: Relay) {
relay.unregister(this)
relays = relays.minus(relay)
updateStatus()
}
fun register(listener: Listener) {
@@ -109,12 +120,14 @@ object RelayPool : Relay.Listener {
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
refreshObservers()
updateStatus()
}
override fun onRelayStateChange(relay: Relay, type: Relay.Type, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
refreshObservers()
if (type != Relay.Type.EOSE) {
updateStatus()
}
}
override fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) {
@@ -125,18 +138,15 @@ object RelayPool : Relay.Listener {
listeners.forEach { it.onAuth(relay, challenge) }
}
// Observers line up here.
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
live.refresh()
private fun updateStatus() {
val connected = connectedRelays()
val available = availableRelays()
if (_lastStatus.connected != connected || _lastStatus.available != available) {
_lastStatus = RelayPoolStatus(connected, available)
_statusFlow.tryEmit(_lastStatus)
}
}
}
class RelayPoolLiveData(val relays: RelayPool) : LiveData<RelayPoolState>(RelayPoolState(relays)) {
fun refresh() {
postValue(RelayPoolState(relays))
}
}
class RelayPoolState(val relays: RelayPool)
@Immutable
data class RelayPoolStatus(val connected: Int, val available: Int, val isConnected: Boolean = connected > 0)

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
@@ -22,6 +23,7 @@ import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
@@ -50,6 +52,8 @@ 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()
@@ -65,7 +69,10 @@ class MainActivity : AppCompatActivity() {
themeViewModel.onChange(LocalPreferences.getTheme())
AmethystTheme(themeViewModel) {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(this@MainActivity)
}
@@ -81,7 +88,8 @@ class MainActivity : AppCompatActivity() {
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
val connectivityManager =
getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.requestNetwork(networkRequest, networkCallback)
}
@@ -94,20 +102,25 @@ 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())
}
}
override fun onPause() {
ServiceManager.cleanObservers()
// if (BuildConfig.DEBUG) {
debugState(this)
// }
ServiceManager.pause()
if (ServiceManager.shouldPauseService) {
ServiceManager.pause()
}
super.onPause()
}
@@ -127,7 +140,7 @@ class MainActivity : AppCompatActivity() {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) {
ServiceManager.cleanUp()
ServiceManager.trimMemory()
}
}
@@ -139,8 +152,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)
}
}
}
@@ -152,7 +167,8 @@ class MainActivity : AppCompatActivity() {
super.onCapabilitiesChanged(network, networkCapabilities)
GlobalScope.launch(Dispatchers.IO) {
val hasMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasMobileData =
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
@@ -169,7 +185,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()
}
}
}
}
@@ -177,9 +195,15 @@ class MainActivity : AppCompatActivity() {
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
@SuppressLint("MissingSuperCall")
override fun createIntent(context: Context, input: String): Intent {
return super.createIntent(context, input).apply {
// Force only images and videos to be selectable
// Force OPEN Document because of the resulting URI must be passed to the
// Playback service and the picker's permissions only allow the activity to read the URI
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
// Force only images and videos to be selectable
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
}
}

View File

@@ -92,24 +92,32 @@ open class NewMediaModel : ViewModel() {
} else {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description, sensitiveContent, relayList = relayList)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
viewModelScope.launch(Dispatchers.IO) {
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(
imageUrl,
mimeType,
description,
sensitiveContent,
relayList = relayList
)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
)
)
}
}
},
onError = {

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Log
import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.Image
@@ -79,13 +80,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
var showRelaysDialog by remember {
mutableStateOf(false)
}
var relayList = account.activeRelays()?.filter {
it.write
}?.map {
it
} ?: account.convertLocalRelays().filter {
it.write
}
var relayList = account.activeWriteRelays()
Dialog(
onDismissRequest = { onClose() },
@@ -101,7 +96,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
) {
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
preSelectedList = relayList,
onClose = {
showRelaysDialog = false
},
@@ -221,7 +216,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
try {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
} catch (e: Exception) {
postViewModel.imageUploadingError.emit("Unable to load file")
postViewModel.imageUploadingError.emit("Unable to load thumbnail, but the video can be uploaded")
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
}

View File

@@ -101,37 +101,50 @@ class NewMessageTagger(
key = key.removePrefix("@")
if (key.length < 63) {
return null
}
try {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
if (key.startsWith("nsec1", true)) {
if (key.length < 63) {
return null
}
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
// Converts to npub
val pubkey = Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("npub1", true)) {
if (key.length < 63) {
return null
}
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("note1", true)) {
if (key.length < 63) {
return null
}
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val noteId = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(noteId, restOfWord)
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
val pubkeyRelay = Nip19.uriToRoute(key) ?: return null
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
} else if (key.startsWith("nevent1", true)) {
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
val noteRelayId = Nip19.uriToRoute(key) ?: return null
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
} else if (key.startsWith("naddr1", true)) {
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
val address = Nip19.uriToRoute(key) ?: return null
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
}

View File

@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
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.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
@@ -11,8 +13,6 @@ import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -21,51 +21,11 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
var textMax by rememberSaveable { mutableStateOf("") }
var textMin by rememberSaveable { mutableStateOf("") }
// check for zapMax amounts < 1
pollViewModel.isValidvalueMaximum.value = true
if (textMax.isNotEmpty()) {
try {
val int = textMax.toInt()
if (int < 1) {
pollViewModel.isValidvalueMaximum.value = false
} else { pollViewModel.valueMaximum = int }
} catch (e: Exception) { pollViewModel.isValidvalueMaximum.value = false }
}
// check for minZap amounts < 1
pollViewModel.isValidvalueMinimum.value = true
if (textMin.isNotEmpty()) {
try {
val int = textMin.toInt()
if (int < 1) {
pollViewModel.isValidvalueMinimum.value = false
} else { pollViewModel.valueMinimum = int }
} catch (e: Exception) { pollViewModel.isValidvalueMinimum.value = false }
}
// check for zapMin > zapMax
if (textMin.isNotEmpty() && textMax.isNotEmpty()) {
try {
val intMin = textMin.toInt()
val intMax = textMax.toInt()
if (intMin > intMax) {
pollViewModel.isValidvalueMinimum.value = false
pollViewModel.isValidvalueMaximum.value = false
}
} catch (e: Exception) {
pollViewModel.isValidvalueMinimum.value = false
pollViewModel.isValidvalueMaximum.value = false
}
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
@@ -80,10 +40,10 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
horizontalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = textMin,
onValueChange = { textMin = it },
value = pollViewModel.valueMinimum?.toString() ?: "",
onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
label = {
Text(
@@ -98,11 +58,14 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
)
}
)
Spacer(modifier = DoubleHorzSpacer)
OutlinedTextField(
value = textMax,
onValueChange = { textMax = it },
value = pollViewModel.valueMaximum?.toString() ?: "",
onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
label = {
Text(
@@ -118,10 +81,25 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
}
)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.poll_zap_value_min_max_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
}
}
@Preview
@Composable
fun NewPollVoteValueRangePreview() {
NewPollVoteValueRange(NewPostViewModel())
Column(
modifier = Modifier.fillMaxWidth()
) {
NewPollVoteValueRange(NewPostViewModel())
}
}

View File

@@ -9,6 +9,7 @@ import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -82,20 +83,24 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -111,6 +116,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.Math.round
@Composable
fun NewPostView(
@@ -133,13 +139,7 @@ fun NewPostView(
var showRelaysDialog by remember {
mutableStateOf(false)
}
var relayList = account.activeRelays()?.filter {
it.write
}?.map {
it
} ?: account.convertLocalRelays().filter {
it.write
}
var relayList = account.activeWriteRelays()
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
@@ -169,7 +169,7 @@ fun NewPostView(
) {
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
preSelectedList = relayList,
onClose = {
showRelaysDialog = false
},
@@ -327,9 +327,9 @@ fun NewPostView(
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp)
) {
FowardZapTo(postViewModel)
FowardZapTo(postViewModel, accountViewModel)
}
}
@@ -518,6 +518,8 @@ private fun PollField(postViewModel: NewPostViewModel) {
NewPollOption(postViewModel, index)
}
NewPollVoteValueRange(postViewModel)
Button(
onClick = {
postViewModel.pollOptions[postViewModel.pollOptions.size] =
@@ -725,7 +727,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
}
@Composable
fun FowardZapTo(postViewModel: NewPostViewModel) {
fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) {
Column(
modifier = Modifier.fillMaxWidth()
) {
@@ -759,7 +761,7 @@ fun FowardZapTo(postViewModel: NewPostViewModel) {
}
Text(
text = stringResource(R.string.zap_forward_title),
text = stringResource(R.string.zap_split_title),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
@@ -769,22 +771,52 @@ fun FowardZapTo(postViewModel: NewPostViewModel) {
Divider()
Text(
text = stringResource(R.string.zap_forward_explainer),
text = stringResource(R.string.zap_split_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
postViewModel.forwardZapTo.items.forEachIndexed { index, splitItem ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
BaseUserPicture(splitItem.key, Size55dp, accountViewModel = accountViewModel)
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(splitItem.key, showPlayButton = false)
Text(
text = String.format("%.0f%%", splitItem.percentage * 100),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Spacer(modifier = DoubleHorzSpacer)
Slider(
value = splitItem.percentage,
onValueChange = { sliderValue ->
val rounded = (round(sliderValue * 20)) / 20.0f
postViewModel.updateZapPercentage(index, rounded)
},
modifier = Modifier
.weight(1.5f)
)
}
}
OutlinedTextField(
value = postViewModel.forwardZapToEditting,
onValueChange = {
postViewModel.updateZapForwardTo(it)
},
label = { Text(text = stringResource(R.string.zap_forward_lnAddress)) },
label = { Text(text = stringResource(R.string.zap_split_serarch_and_add_user)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.zap_forward_lnAddress),
text = stringResource(R.string.zap_split_serarch_and_add_user_placeholder),
color = MaterialTheme.colors.placeholderText
)
},
@@ -1374,8 +1406,8 @@ fun ImageVideoDescription(
try {
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
} catch (e: Exception) {
onError("Unable to load file")
Log.e("NewPostView", "Couldn't create thumbnail for $uri")
onError("Unable to load thumbnail")
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
}

View File

@@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
@@ -29,6 +30,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -73,8 +75,8 @@ open class NewPostViewModel() : ViewModel() {
var wantsPoll by mutableStateOf(false)
var zapRecipients = mutableStateListOf<HexKey>()
var pollOptions = newStateMapPollOptions()
var valueMaximum: Int? = null
var valueMinimum: Int? = null
var valueMaximum by mutableStateOf<Int?>(null)
var valueMinimum by mutableStateOf<Int?>(null)
var consensusThreshold: Int? = null
var closedAt: Int? = null
@@ -88,9 +90,13 @@ open class NewPostViewModel() : ViewModel() {
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
data class ForwardZapSetup(val user: User) {
var percentage by mutableStateOf(100)
}
// Forward Zap to
var wantsForwardZapTo by mutableStateOf(false)
var forwardZapTo by mutableStateOf<User?>(null)
var forwardZapTo by mutableStateOf<Split<User>>(Split())
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
// NSFW, Sensitive
@@ -151,7 +157,7 @@ open class NewPostViewModel() : ViewModel() {
wantsToAddGeoHash = false
wantsZapraiser = false
zapRaiserAmount = null
forwardZapTo = null
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
this.account = account
@@ -166,10 +172,13 @@ open class NewPostViewModel() : ViewModel() {
val dmUsers = toUsersTagger.mentions
val zapReceiver = if (wantsForwardZapTo) {
if (forwardZapTo != null) {
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
} else {
forwardZapToEditting.text
forwardZapTo?.items?.map {
ZapSplitSetup(
lnAddressOrPubKeyHex = it.key.pubkeyHex,
relay = it.key.relaysBeingUsed.keys.firstOrNull(),
weight = it.percentage.toDouble(),
isLnAddress = false
)
}
} else {
null
@@ -355,7 +364,7 @@ open class NewPostViewModel() : ViewModel() {
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
forwardZapTo = null
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
userSuggestions = emptyList()
@@ -427,10 +436,10 @@ open class NewPostViewModel() : ViewModel() {
open fun updateZapForwardTo(it: TextFieldValue) {
forwardZapToEditting = it
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWord = it.text
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
if (lastWord.startsWith("@") && lastWord.length > 2) {
if (lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
@@ -460,6 +469,9 @@ open class NewPostViewModel() : ViewModel() {
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo?.addItem(item)
forwardZapToEditting = TextFieldValue("")
/*
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
@@ -468,7 +480,7 @@ open class NewPostViewModel() : ViewModel() {
forwardZapToEditting = TextFieldValue(
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
)*/
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
@@ -494,7 +506,7 @@ open class NewPostViewModel() : ViewModel() {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) &&
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
contentToAddUrl == null
}
@@ -612,6 +624,54 @@ open class NewPostViewModel() : ViewModel() {
nip24 = !nip24
}
}
fun updateMinZapAmountForPoll(textMin: String) {
if (textMin.isNotEmpty()) {
try {
val int = textMin.toInt()
if (int < 1) {
valueMinimum = null
} else {
valueMinimum = int
}
} catch (e: Exception) {}
} else {
valueMinimum = null
}
checkMinMax()
}
fun updateMaxZapAmountForPoll(textMax: String) {
if (textMax.isNotEmpty()) {
try {
val int = textMax.toInt()
if (int < 1) {
valueMaximum = null
} else {
valueMaximum = int
}
} catch (e: Exception) {}
} else {
valueMaximum = null
}
checkMinMax()
}
fun checkMinMax() {
if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) {
isValidvalueMinimum.value = false
isValidvalueMaximum.value = false
} else {
isValidvalueMinimum.value = true
isValidvalueMaximum.value = true
}
}
fun updateZapPercentage(index: Int, sliderValue: Float) {
forwardZapTo?.updatePercentage(index, sliderValue)
}
}
enum class GeohashPrecision(val digits: Int) {

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
@@ -24,6 +25,7 @@ class NewRelayListViewModel : ViewModel() {
fun load(account: Account) {
this.account = account
clear()
loadRelayDocuments()
}
fun create() {
@@ -36,17 +38,34 @@ class NewRelayListViewModel : ViewModel() {
clear()
}
fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) {
_relays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
togglePaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage ->
}
)
}
}
}
fun clear() {
_relays.update {
var relayFile = account.userProfile().latestContactList?.relays()
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
if (relayFile?.none { it.key == Constants.forcedRelayForSearch.url } == true) {
relayFile = relayFile + Pair(
Constants.forcedRelayForSearch.url,
ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write)
)
if (relayFile?.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } == true) {
relayFile = relayFile + Constants.forcedRelayForSearch.map {
Pair(
it.url,
ContactListEvent.ReadWrite(it.read, it.write)
)
}
}
if (relayFile != null) {

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

@@ -1,8 +1,5 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -28,27 +25,24 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RelayInformationDialog(
onClose: () -> Unit,
relayBriefInfo: RelayBriefInfo,
relayInfo: RelayInformation,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -79,18 +73,25 @@ fun RelayInformationDialog(
})
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Title(relayInfo.name ?: "")
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = StdPadding.fillMaxWidth()) {
Column() {
RenderRelayIcon(
relayBriefInfo.favIcon,
Size55dp
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
SectionContent(relayInfo.description ?: "")
Spacer(modifier = DoubleHorzSpacer)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row() {
Title(relayInfo.name?.trim() ?: "")
}
Row() {
SubtitleContent(relayInfo.description?.trim() ?: "")
}
}
}
Section(stringResource(R.string.owner))
@@ -212,7 +213,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
val text = item.toString().padStart(2, '0')
Box(Modifier.padding(10.dp)) {
ClickableUrl(
urlText = "$text",
urlText = text,
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
)
}
@@ -222,7 +223,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
val text = item.padStart(2, '0')
Box(Modifier.padding(10.dp)) {
ClickableUrl(
urlText = "$text",
urlText = text,
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
)
}
@@ -258,13 +259,18 @@ private fun DisplayOwnerInformation(
@Composable
fun Title(text: String) {
Spacer(modifier = DoubleVertSpacer)
Text(
text = text,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
Spacer(modifier = DoubleVertSpacer)
}
@Composable
fun SubtitleContent(text: String) {
Text(
text = text
)
}
@Composable
@@ -285,85 +291,3 @@ fun SectionContent(text: String) {
text = text
)
}
fun loadRelayInfo(
dirtyUrl: String,
context: Context,
scope: CoroutineScope,
onInfo: (RelayInformation) -> Unit
) {
try {
val url = if (dirtyUrl.contains("://")) {
dirtyUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}
}
override fun onFailure(call: Call, e: IOException) {
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}
)
} catch (e: Exception) {
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}

View File

@@ -29,19 +29,27 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
data class RelayList(
val relay: Relay,
val relayInfo: RelayBriefInfo,
val isSelected: Boolean
)
data class RelayInfoDialog(
val relayBriefInfo: RelayBriefInfo,
val relayInfo: RelayInformation
)
@Composable
fun RelaySelectionDialog(
list: List<Relay>,
preSelectedList: List<Relay>,
onClose: () -> Unit,
onPost: (list: List<Relay>) -> Unit,
accountViewModel: AccountViewModel,
@@ -49,34 +57,29 @@ fun RelaySelectionDialog(
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val relayList = accountViewModel.account.activeRelays()?.filter {
it.write
}?.map {
it
} ?: accountViewModel.account.convertLocalRelays().filter {
it.write
}
var relays by remember {
mutableStateOf(
relayList.map {
accountViewModel.account.activeWriteRelays().map {
RelayList(
it,
list.any { relay -> it.url == relay.url }
relay = it,
relayInfo = RelayBriefInfo(it.url),
isSelected = preSelectedList.any { relay -> it.url == relay.url }
)
}
)
}
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
if (relayInfo != null) {
relayInfo?.let {
RelayInformationDialog(
onClose = {
relayInfo = null
},
relayInfo = relayInfo!!,
accountViewModel,
nav
relayInfo = it.relayInfo,
relayBriefInfo = it.relayBriefInfo,
accountViewModel = accountViewModel,
nav = nav
)
}
@@ -115,14 +118,14 @@ fun RelaySelectionDialog(
}
)
PostButton(
SaveButton(
onPost = {
val selectedRelays = relays.filter { it.isSelected }
if (selectedRelays.isEmpty()) {
scope.launch {
Toast.makeText(context, context.getString(R.string.select_a_relay_to_continue), Toast.LENGTH_SHORT).show()
}
return@PostButton
return@SaveButton
}
onPost(selectedRelays.map { it.relay })
onClose()
@@ -153,10 +156,7 @@ fun RelaySelectionDialog(
key = { _, item -> item.relay.url }
) { index, item ->
RelaySwitch(
text = item.relay.url
.removePrefix("ws://")
.removePrefix("wss://")
.removeSuffix("/"),
text = item.relayInfo.displayUrl,
checked = item.isSelected,
onClick = {
relays = relays.mapIndexed { j, item ->
@@ -168,9 +168,30 @@ fun RelaySelectionDialog(
}
},
onLongPress = {
loadRelayInfo(item.relay.url, context, scope) {
relayInfo = it
}
accountViewModel.retrieveRelayDocument(
item.relay.url,
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfo(item.relay.url), it)
},
onError = { url, errorCode, exceptionMessage ->
val msg = when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
}
scope.launch {
Toast
.makeText(
context,
msg,
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
)
}

View File

@@ -59,38 +59,38 @@ fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color)
)
)
newWord + restOfWord
} else if (Patterns.WEB_URL.matcher(word).matches()) {
val startIndex = builderBefore.toString().length
val endIndex = startIndex + word.length
val startNew = builderAfter.toString().length
val endNew = startNew + word.length
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, endNew)
)
)
builderBefore.append("$word ")
builderAfter.append("$word ")
word
} else {
builderBefore.append(word + " ")
builderAfter.append(word + " ")
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
} catch (e: Exception) {
// if it can't parse the key, don't try to change.
builderBefore.append(word + " ")
builderAfter.append(word + " ")
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
}.joinToString(" ")
}.joinToString("\n")
)
val newText = toAnnotatedString()
newText.split("\\s+".toRegex()).filter { word ->
Patterns.WEB_URL.matcher(word).matches()
}.forEach {
val startIndex = text.indexOf(it)
val endIndex = startIndex + it.length
addStyle(
style = SpanStyle(
color = color,
textDecoration = TextDecoration.None
),
start = startIndex,
end = endIndex
)
}
substitutions.forEach {
addStyle(
style = SpanStyle(

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
@@ -12,6 +13,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.encoders.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -41,10 +43,7 @@ fun MayBeWithdrawal(lnurlWord: String) {
@Composable
fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current
val uri = remember(withdrawalString) {
Uri.parse("lightning:$withdrawalString")
}
val scope = rememberCoroutineScope()
val withdraw = remember(withdrawalString) {
AnnotatedString("$withdrawalString ")
@@ -53,9 +52,18 @@ fun ClickableWithdrawal(withdrawalString: String) {
ClickableText(
text = withdraw,
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.lightning_wallets_not_found),
Toast.LENGTH_LONG
).show()
}
}
},
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
@@ -66,6 +67,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
@Composable
fun InvoicePreview(lnInvoice: String, amount: String?) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
@@ -118,9 +120,18 @@ fun InvoicePreview(lnInvoice: String, amount: String?) {
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
runCatching {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(context, intent, null)
} catch (e: Exception) {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.lightning_wallets_not_found),
Toast.LENGTH_LONG
).show()
}
}
},
shape = QuoteBorder,

View File

@@ -0,0 +1,99 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlin.math.abs
class SplitItem<T>(val key: T) {
var percentage by mutableStateOf(0f)
}
class Split<T>() {
var items: List<SplitItem<T>> by mutableStateOf(emptyList())
fun addItem(key: T): Int {
val wasEqualSplit = isEqualSplit()
val newItem = SplitItem(key)
items = items.plus(newItem)
if (wasEqualSplit) {
forceEqualSplit()
} else {
updatePercentage(items.lastIndex, equalSplit())
}
return items.lastIndex
}
fun equalSplit() = 1f / items.size
fun isEqualSplit(): Boolean {
val expectedPercentage = equalSplit()
return items.all { (it.percentage - expectedPercentage) < 0.01 }
}
fun forceEqualSplit() {
val correctPercentage = equalSplit()
items.forEach {
it.percentage = correctPercentage
}
}
fun updatePercentage(index: Int, percentage: Float) {
if (items.isEmpty()) return
val splitItem = items.getOrNull(index) ?: return
if (items.size == 1) {
splitItem.percentage = 1f
} else {
splitItem.percentage = percentage
println("Update ${items[index].key} to $percentage")
val othersMustShare = 1.0f - splitItem.percentage
val othersHave = items.sumOf {
if (it == splitItem) 0.0 else it.percentage.toDouble()
}.toFloat()
if (abs(othersHave - othersMustShare) < 0.01) return // nothing to do
println("Others Must Share $othersMustShare but have $othersHave")
bottomUpAdjustment(othersMustShare, othersHave, index)
}
}
private fun bottomUpAdjustment(othersMustShare: Float, othersHave: Float, exceptForIndex: Int) {
var needToRemove = othersHave - othersMustShare
if (needToRemove > 0) {
for (i in items.indices.reversed()) {
if (i == exceptForIndex) continue // do not update the current item
if (needToRemove < items[i].percentage) {
val oldValue = items[i].percentage
items[i].percentage -= needToRemove
needToRemove = 0f
println("- Updating ${items[i].key} from $oldValue to ${items[i].percentage - needToRemove}. $needToRemove left")
} else {
val oldValue = items[i].percentage
needToRemove -= items[i].percentage
items[i].percentage = 0f
println("- Updating ${items[i].key} from $oldValue to ${0}. $needToRemove left")
}
if (needToRemove < 0.01) {
break
}
}
} else if (needToRemove < 0) {
if (items.lastIndex == exceptForIndex) {
items[items.lastIndex - 1].percentage += -needToRemove
} else {
items.last().percentage += -needToRemove
}
}
}
}

View File

@@ -12,8 +12,6 @@ import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
@@ -37,10 +35,8 @@ fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel)
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
urlPreviewState = it
}
accountViewModel.urlPreview(url) {
urlPreviewState = it
}
}
}

View File

@@ -704,6 +704,7 @@ fun ControlWhenPlayerIsActive(
controller.addListener(listener)
onDispose {
view.keepScreenOn = false
controller.removeListener(listener)
}
}

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

@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -50,7 +49,6 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()

View File

@@ -6,7 +6,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.PrivateDmEvent
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -38,7 +37,6 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()

View File

@@ -2,11 +2,9 @@ package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
abstract class FeedFilter<T> {
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
checkNotInMainThread()
@@ -33,7 +31,6 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(collection: Set<T>): Set<T>
abstract fun sort(collection: Set<T>): List<T>
@OptIn(ExperimentalTime::class)
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
checkNotInMainThread()

View File

@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -34,6 +35,7 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
it.event is LongTextNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent ||
it.event is PollNoteEvent ||
it.event is AudioHeaderEvent
) &&
it.event?.isTaggedGeoHash(myTag) == true

View File

@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -34,6 +35,7 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
it.event is LongTextNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent ||
it.event is PollNoteEvent ||
it.event is AudioHeaderEvent
) &&
it.event?.isTaggedHash(myTag) == true

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

@@ -1,22 +1,30 @@
package com.vitorpamplona.amethyst.ui.dal
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class ThreadFeedFilter(val noteId: String) : FeedFilter<Note>() {
class ThreadFeedFilter(val account: Account, val noteId: String) : FeedFilter<Note>() {
override fun feedKey(): String {
return noteId
}
override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: emptySet()
val cachedSignatures: MutableMap<Note, Note.LevelSignature> = mutableMapOf()
val followingSet = account.selectedUsersFollowList(KIND3_FOLLOWS) ?: emptySet()
val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
val now = TimeUtils.now()
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature(eventsToWatch, cachedSignatures) }
val order = compareByDescending<Note> {
it.replyLevelSignature(eventsInHex, cachedSignatures, account.userProfile(), followingSet, now).signature
}
return eventsToWatch.sortedWith(order)
}

View File

@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -80,7 +83,12 @@ fun AppNavigation(
}
}
NavHost(navController, startDestination = Route.Home.route) {
NavHost(
navController,
startDestination = Route.Home.route,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
Route.Home.let { route ->
composable(route.route, route.arguments, content = { it ->
val nip47 = it.arguments?.getString("nip47")
@@ -215,6 +223,7 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
ChatroomScreen(
roomId = it.arguments?.getString("id"),
draftMessage = it.arguments?.getString("message"),
accountViewModel = accountViewModel,
nav = nav
)

View File

@@ -451,8 +451,6 @@ fun GenericMainTopBar(
nav: (String) -> Unit,
content: @Composable (AccountViewModel) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = BottomTopHeight) {
MyTopAppBar(
elevation = 0.dp,
@@ -476,6 +474,7 @@ fun GenericMainTopBar(
}
},
navigationIcon = {
val coroutineScope = rememberCoroutineScope()
LoggedInUserPictureDrawer(accountViewModel) {
coroutineScope.launch {
scaffoldState.drawerState.open()
@@ -506,10 +505,9 @@ private fun LoggedInUserPictureDrawer(
accountViewModel: AccountViewModel,
onClick: () -> Unit
) {
val accountUserState by accountViewModel.account.userProfile().live().metadata.observeAsState()
val profilePicture by accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState()
val pubkeyHex = remember { accountUserState?.user?.pubkeyHex ?: "" }
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex }
IconButton(
onClick = onClick
@@ -754,9 +752,9 @@ fun debugState(context: Context) {
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + " / " + LocalCache.notes.size)
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + " / " + LocalCache.addressables.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + " / " + LocalCache.users.size)
Log.d("STATE DUMP", "Memory used by Events: " + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB")

View File

@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
@@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.Send
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -56,12 +58,12 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
@@ -70,11 +72,12 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
@@ -107,7 +110,7 @@ fun DrawerContent(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp)
.padding(top = 100.dp),
.padding(top = 70.dp),
scaffoldState,
accountViewModel,
nav
@@ -160,7 +163,7 @@ fun ProfileContent(
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.height(120.dp)
)
} else {
Image(
@@ -169,7 +172,7 @@ fun ProfileContent(
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.height(120.dp)
)
}
@@ -218,7 +221,6 @@ fun ProfileContent(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 15.dp)
.clickable(
onClick = {
nav(route)
@@ -278,16 +280,21 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences
),
keyboardActions = KeyboardActions(
onSend = {
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
),
singleLine = true,
trailingIcon = {
if (hasChanged) {
UserStatusSendButton() {
scope.launch(Dispatchers.IO) {
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
}
}
@@ -319,24 +326,26 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences
),
keyboardActions = KeyboardActions(
onSend = {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
),
singleLine = true,
trailingIcon = {
if (hasChanged) {
UserStatusSendButton() {
scope.launch(Dispatchers.IO) {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
} else {
UserStatusDeleteButton() {
scope.launch(Dispatchers.IO) {
accountViewModel.updateStatus(it, "")
accountViewModel.delete(it)
focusManager.clearFocus(true)
}
accountViewModel.deleteStatus(it)
focusManager.clearFocus(true)
}
}
}
@@ -446,7 +455,6 @@ fun ListContent(
}
val coroutineScope = rememberCoroutineScope()
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
var wantsToEditRelays by remember {
mutableStateOf(false)
}
@@ -482,7 +490,7 @@ fun ListContent(
)
IconRowRelays(
relayViewModel = relayViewModel,
accountViewModel = accountViewModel,
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
@@ -625,27 +633,37 @@ private fun enableTor(
}
@Composable
private fun RelayStatus(
relayViewModel: RelayPoolViewModel
) {
val connectedRelaysText by relayViewModel.connectionStatus.observeAsState("--/--")
val isConnected by relayViewModel.isConnected.observeAsState(false)
private fun RelayStatus(accountViewModel: AccountViewModel) {
val connectedRelaysText by RelayPool.statusFlow.collectAsState(initial = RelayPoolStatus(0, 0))
RenderRelayStatus(connectedRelaysText, isConnected)
RenderRelayStatus(connectedRelaysText)
}
@Composable
private fun RenderRelayStatus(
connectedRelaysText: String,
isConnected: Boolean
relayPool: RelayPoolStatus
) {
val text by remember(relayPool) {
derivedStateOf {
"${relayPool.connected}/${relayPool.available}"
}
}
val placeHolder = MaterialTheme.colors.placeholderText
val color by remember(relayPool) {
derivedStateOf {
if (relayPool.isConnected) {
placeHolder
} else {
Color.Red
}
}
}
Text(
text = connectedRelaysText,
color = if (isConnected) {
MaterialTheme.colors.placeholderText
} else {
Color.Red
},
text = text,
color = color,
style = MaterialTheme.typography.subtitle1
)
}
@@ -701,7 +719,7 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongCl
}
@Composable
fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
fun IconRowRelays(accountViewModel: AccountViewModel, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -728,7 +746,7 @@ fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
Spacer(modifier = Modifier.width(Size16dp))
RelayStatus(relayViewModel = relayViewModel)
RelayStatus(accountViewModel = accountViewModel)
}
}
}

View File

@@ -120,9 +120,12 @@ sealed class Route(
)
object Room : Route(
route = "Room/{id}",
route = "Room/{id}?message={message}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
arguments = listOf(
navArgument("id") { type = NavType.StringType },
navArgument("message") { type = NavType.StringType; nullable = true; defaultValue = null }
).toImmutableList()
)
object 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

@@ -62,57 +62,63 @@ fun BlankNote(modifier: Modifier = Modifier, showDivider: Boolean = false, idHex
@Composable
fun HiddenNote(
reports: ImmutableSet<Note>,
isHiddenAuthor: Boolean,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
isQuote: Boolean = false,
nav: (String) -> Unit,
onClick: () -> Unit
) {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
Row(
modifier = Modifier.padding(
start = 20.dp,
end = 20.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(30.dp)) {
Text(
text = stringResource(R.string.post_was_flagged_as_inappropriate_by),
color = Color.Gray
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Row(
modifier = Modifier.padding(start = if (!isQuote) 30.dp else 25.dp, end = 20.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(30.dp)
) {
Text(
text = stringResource(R.string.post_was_flagged_as_inappropriate_by),
color = Color.Gray
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
if (isHiddenAuthor) {
UserPicture(
user = accountViewModel.userProfile(),
size = Size35dp,
nav = nav,
accountViewModel = accountViewModel
)
}
reports.forEach {
NoteAuthorPicture(
baseNote = it,
size = Size35dp,
nav = nav,
accountViewModel = accountViewModel
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
reports.forEach {
NoteAuthorPicture(
baseNote = it,
nav = nav,
accountViewModel = accountViewModel,
size = Size35dp
)
}
}
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_anyway), color = Color.White)
}
}
}
Divider(
thickness = 0.25.dp
)
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_anyway), color = Color.White)
}
}
}
Divider(
thickness = 0.25.dp
)
}
}

View File

@@ -76,7 +76,6 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -135,13 +134,9 @@ fun CheckHiddenChannelCardCompose(
nav: (String) -> Unit
) {
if (showHidden) {
var state by remember {
val state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
@@ -185,19 +180,14 @@ fun LoadedChannelCardCompose(
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
val scope = rememberCoroutineScope()
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
WatchForReports(note, accountViewModel) { newState ->
if (state != newState) {
scope.launch(Dispatchers.Main) {
state = newState
}
@@ -219,7 +209,7 @@ fun LoadedChannelCardCompose(
@Composable
fun RenderChannelCardReportState(
state: NoteComposeReportState,
state: AccountViewModel.NoteComposeReportState,
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
@@ -233,6 +223,7 @@ fun RenderChannelCardReportState(
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
state.isHiddenAuthor,
accountViewModel,
modifier,
false,
@@ -625,7 +616,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
}
description?.let {
@@ -739,7 +730,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
}
description?.let {

View File

@@ -256,11 +256,10 @@ private fun UserRoomCompose(
note.createdAt()
}
}
val content by remember(note) {
derivedStateOf {
mutableStateOf(
accountViewModel.decrypt(note)
}
)
}
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->

View File

@@ -69,8 +69,6 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -152,17 +150,13 @@ fun LoadedChatMessageCompose(
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
WatchForReports(baseNote, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
state = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
WatchForReports(baseNote, accountViewModel) { newState ->
if (state != newState) {
state = newState
}
}
@@ -178,6 +172,7 @@ fun LoadedChatMessageCompose(
if (it) {
HiddenNote(
state.relevantReports,
state.isHiddenAuthor,
accountViewModel,
Modifier,
innerQuote,
@@ -566,7 +561,7 @@ private fun StatusRow(
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
ReplyReaction(
baseNote = baseNote,
@@ -632,7 +627,7 @@ private fun RenderRegularTextNote(
nav: (String) -> Unit
) {
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val eventContent = remember { accountViewModel.decrypt(note) }
val eventContent by remember { mutableStateOf(accountViewModel.decrypt(note)) }
val modifier = remember { Modifier.padding(top = 5.dp) }
if (eventContent != null) {
@@ -641,7 +636,7 @@ private fun RenderRegularTextNote(
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
content = eventContent,
content = eventContent!!,
canPreview = canPreview,
modifier = modifier,
tags = tags,

View File

@@ -1,7 +1,10 @@
package com.vitorpamplona.amethyst.ui.note
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -42,7 +45,6 @@ import androidx.compose.ui.text.font.FontWeight
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.components.ImageUrlType
@@ -52,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
@@ -70,8 +71,6 @@ 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.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -352,8 +351,7 @@ private fun ParseAuthorCommentAndAmount(
}
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
launch(Dispatchers.IO) {
val newState = loadAmountState(zapRequest, zapEvent, accountViewModel)
accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState ->
if (newState != null) {
content.value = newState
}
@@ -363,32 +361,10 @@ private fun ParseAuthorCommentAndAmount(
onReady(content)
}
private suspend fun loadAmountState(
zapRequest: Note,
zapEvent: Note?,
accountViewModel: AccountViewModel
): ZapAmountCommentNotification? {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
return ZapAmountCommentNotification(
newAuthor,
decryptedContent.content.ifBlank { null },
showAmountAxis(amount)
)
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
return ZapAmountCommentNotification(
zapRequest.author,
zapRequest.event?.content()?.ifBlank { null },
showAmountAxis(amount)
)
}
}
fun click(content: MutableState<ZapAmountCommentNotification>, nav: (String) -> Unit) {
content.value.user?.let {
nav(routeFor(it))
}
return null
}
@Composable
@@ -399,11 +375,7 @@ private fun RenderState(
nav: (String) -> Unit
) {
Row(
modifier = Modifier.clickable {
content.value.user?.let {
nav(routeFor(it))
}
},
modifier = Modifier.clickable { click(content, nav) },
verticalAlignment = Alignment.CenterVertically
) {
DisplayAuthorCommentAndAmount(
@@ -451,8 +423,19 @@ fun CrossfadeToDisplayPicture(authorComment: MutableState<ZapAmountCommentNotifi
@Composable
fun CrossfadeToDisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
Crossfade(authorComment.value, modifier = amountBoxModifier) {
it.amount?.let {
val visible by remember(authorComment) {
derivedStateOf {
authorComment.value.amount != null
}
}
AnimatedVisibility(
visible = visible,
modifier = amountBoxModifier,
enter = fadeIn(),
exit = fadeOut()
) {
authorComment.value.amount?.let {
Box(
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
@@ -486,8 +469,18 @@ fun CrossfadeToDisplayComment(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Crossfade(authorComment.value) {
it.comment?.let {
val visible by remember(authorComment) {
derivedStateOf {
authorComment.value.comment != null
}
}
AnimatedVisibility(
visible,
enter = fadeIn(),
exit = fadeOut()
) {
authorComment.value.comment?.let {
TranslatableRichTextViewer(
content = it,
canPreview = true,

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Icon
@@ -33,7 +34,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon
@@ -45,7 +45,8 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
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.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -53,13 +54,11 @@ import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
@Composable
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): MutableState<Boolean?> {
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String, accountViewModel: AccountViewModel): MutableState<Boolean?> {
val nip05Verified = remember(userMetadata.nip05) {
// starts with null if must verify or already filled in if verified in the last hour
val default = if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) {
@@ -73,37 +72,9 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu
if (nip05Verified.value == null) {
LaunchedEffect(key1 = userMetadata.nip05) {
launch(Dispatchers.IO) {
userMetadata.nip05?.ifBlank { null }?.let { nip05 ->
Nip05Verifier().verifyNip05(
nip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
userMetadata.nip05Verified = true
userMetadata.nip05LastVerificationTime = TimeUtils.now()
if (nip05Verified.value != true) {
nip05Verified.value = true
}
} else {
userMetadata.nip05Verified = false
userMetadata.nip05LastVerificationTime = 0
if (nip05Verified.value != false) {
nip05Verified.value = false
}
}
},
onError = {
userMetadata.nip05LastVerificationTime = 0
userMetadata.nip05Verified = false
if (nip05Verified.value != false) {
nip05Verified.value = false
}
}
)
accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus ->
if (nip05Verified.value != newVerificationStatus) {
nip05Verified.value = newVerificationStatus
}
}
}
@@ -154,7 +125,7 @@ private fun VerifyAndDisplayNIP05OrStatusLine(
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05 != null) {
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel)
if (nip05Verified.value != true) {
DisplayNIP05(nip05, nip05Verified)
@@ -180,7 +151,7 @@ fun RotateStatuses(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var indexToDisplay by remember {
var indexToDisplay by remember(statuses) {
mutableIntStateOf(0)
}
@@ -190,7 +161,7 @@ fun RotateStatuses(
LaunchedEffect(Unit) {
while (true) {
delay(10.seconds)
indexToDisplay = ((indexToDisplay + 1) % (statuses.size + 1))
indexToDisplay = (indexToDisplay + 1) % statuses.size
}
}
}
@@ -233,7 +204,7 @@ fun DisplayStatus(
"music" -> Icon(
painter = painterResource(id = R.drawable.tunestr),
null,
modifier = Size18Modifier,
modifier = Size15Modifier.padding(end = Size5dp),
tint = MaterialTheme.colors.placeholderText
)
else -> {}
@@ -249,6 +220,7 @@ fun DisplayStatus(
if (url != null) {
val uri = LocalUriHandler.current
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = { runCatching { uri.openUri(url.trim()) } }
@@ -263,6 +235,7 @@ fun DisplayStatus(
} else if (nostrATag != null) {
LoadAddressableNote(nostrATag) { note ->
if (note != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = {
@@ -284,6 +257,7 @@ fun DisplayStatus(
} else if (nostrHexID != null) {
LoadNote(baseNoteHex = nostrHexID) {
if (it != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = {
@@ -353,12 +327,12 @@ private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>, modifier:
}
@Composable
fun DisplayNip05ProfileStatus(user: User) {
fun DisplayNip05ProfileStatus(user: User, accountViewModel: AccountViewModel) {
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size <= 2) {
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex)
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) {
NIP05VerifiedSymbol(nip05Verified, Size16Modifier)
var domainPadStart = 5.dp

View File

@@ -31,14 +31,17 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.darkColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
@@ -83,6 +86,7 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
@@ -117,11 +121,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
@@ -165,6 +171,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
@@ -172,6 +179,7 @@ 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.EventInterface
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
@@ -195,7 +203,6 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
@@ -276,11 +283,7 @@ fun CheckHiddenNoteCompose(
// Ignores reports as well
val state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
@@ -323,13 +326,6 @@ fun CheckHiddenNoteCompose(
}
}
@Immutable
data class NoteComposeReportState(
val isAcceptable: Boolean,
val canPreview: Boolean,
val relevantReports: ImmutableSet<Note>
)
@Composable
fun LoadedNoteCompose(
note: Note,
@@ -346,19 +342,14 @@ fun LoadedNoteCompose(
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
val scope = rememberCoroutineScope()
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
WatchForReports(note, accountViewModel) { newState ->
if (state != newState) {
scope.launch(Dispatchers.Main) {
state = newState
}
@@ -385,7 +376,7 @@ fun LoadedNoteCompose(
@Composable
fun RenderReportState(
state: NoteComposeReportState,
state: AccountViewModel.NoteComposeReportState,
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
@@ -404,6 +395,7 @@ fun RenderReportState(
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
state.isHiddenAuthor,
accountViewModel,
modifier,
isBoostedNote,
@@ -435,15 +427,13 @@ fun RenderReportState(
fun WatchForReports(
note: Note,
accountViewModel: AccountViewModel,
onChange: (Boolean, Boolean, ImmutableSet<Note>) -> Unit
onChange: (AccountViewModel.NoteComposeReportState) -> Unit
) {
val userFollowsState by accountViewModel.userFollows.observeAsState()
val noteReportsState by note.live().reports.observeAsState()
LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState) {
launch(Dispatchers.Default) {
accountViewModel.isNoteAcceptable(note, onChange)
}
accountViewModel.isNoteAcceptable(note, onChange)
}
}
@@ -784,9 +774,9 @@ private fun ShortCommunityActionOptions(
nav: (String) -> Unit
) {
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (!isFollowing) {
@@ -1072,6 +1062,62 @@ private fun NoteBody(
accountViewModel,
nav
)
val noteEvent = baseNote.event
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
if (zapSplits && noteEvent != null) {
Spacer(modifier = HalfDoubleVertSpacer)
DisplayZapSplits(noteEvent, accountViewModel, nav)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayZapSplits(noteEvent: EventInterface, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val list = remember(noteEvent) { noteEvent.zapSplitSetup() }
if (list.isEmpty()) return
Row(verticalAlignment = CenterVertically) {
Box(
Modifier
.height(20.dp)
.width(25.dp)
) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange
)
Icon(
imageVector = Icons.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange
)
}
Spacer(modifier = StdHorzSpacer)
FlowRow {
list.forEach {
if (it.isLnAddress) {
ClickableText(
text = AnnotatedString(it.lnAddressOrPubKeyHex),
onClick = { },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {
UserPicture(userHex = it.lnAddressOrPubKeyHex, size = 25.dp, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
}
@Composable
@@ -1238,6 +1284,16 @@ fun routeFor(note: Note, loggedIn: User): String? {
return null
}
fun routeToMessage(user: User, draftMessage: String?, accountViewModel: AccountViewModel): String {
val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex))
accountViewModel.account.userProfile().createChatroom(withKey)
return if (draftMessage != null) {
"Room/${withKey.hashCode()}?message=$draftMessage"
} else {
"Room/${withKey.hashCode()}"
}
}
fun routeFor(note: Channel): String {
return "Channel/${note.idHex}"
}
@@ -1315,7 +1371,7 @@ fun RenderPoll(
nav: (String) -> Unit
) {
val noteEvent = note.event as? PollNoteEvent ?: return
val eventContent = remember { noteEvent.content() }
val eventContent = remember(note) { noteEvent.content() }
if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
Text(
@@ -1567,10 +1623,8 @@ 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) }
@@ -1580,7 +1634,7 @@ private fun RenderPrivateMessage(
if (eventContent != null) {
if (makeItShort && isAuthorTheLoggedUser) {
Text(
text = eventContent,
text = eventContent!!,
color = MaterialTheme.colors.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis
@@ -1591,7 +1645,7 @@ private fun RenderPrivateMessage(
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
content = eventContent,
content = eventContent!!,
canPreview = canPreview && !makeItShort,
modifier = modifier,
tags = tags,
@@ -1601,7 +1655,7 @@ private fun RenderPrivateMessage(
)
}
DisplayUncitedHashtags(hashtags, eventContent, nav)
DisplayUncitedHashtags(hashtags, eventContent!!, nav)
}
}
} else {
@@ -1632,9 +1686,9 @@ fun DisplayRelaySet(
) {
val noteEvent = baseNote.event as? RelaySetEvent ?: return
val relays by remember {
mutableStateOf<ImmutableList<String>>(
noteEvent.relays().toImmutableList()
val relays by remember(baseNote) {
mutableStateOf(
noteEvent.relays().map { RelayBriefInfo(it) }.toImmutableList()
)
}
@@ -1689,7 +1743,7 @@ fun DisplayRelaySet(
toMembersShow.forEach { relay ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
Text(
relay.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/"),
text = relay.displayUrl,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -1699,7 +1753,7 @@ fun DisplayRelaySet(
)
Column(modifier = Modifier.padding(start = 10.dp)) {
RelayOptionsAction(relay, accountViewModel, nav)
RelayOptionsAction(relay.url, accountViewModel, nav)
}
}
}
@@ -2399,46 +2453,47 @@ private fun ReplyRow(
) {
val noteEvent = note.event
val showReply by remember {
val showReply by remember(note) {
derivedStateOf {
noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
}
}
val showChannelInfo by remember(note) {
derivedStateOf {
if (noteEvent is ChannelMessageEvent || noteEvent is LiveActivitiesChatMessageEvent) {
note.channelHex()
} else {
null
}
}
}
showChannelInfo?.let {
ChannelHeader(
channelHex = it,
showVideo = false,
showBottomDiviser = false,
sendToChannel = true,
modifier = MaterialTheme.colors.replyModifier.padding(10.dp),
accountViewModel = accountViewModel,
nav = nav
)
}
if (showReply) {
val replyingDirectlyTo = remember { note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } }
if (replyingDirectlyTo != null && unPackReply) {
ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
} else {
// ReplyInformation(note.replyTo, noteEvent.mentions(), accountViewModel, nav)
}
} else {
val showChannelReply by remember {
derivedStateOf {
(noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) ||
(noteEvent is LiveActivitiesChatMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()))
} else if (showChannelInfo != null) {
val replies = remember { note.replyTo?.toImmutableList() }
val mentions = remember {
(note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList()
?: persistentListOf()
}
}
if (showChannelReply) {
val channelHex = note.channelHex()
channelHex?.let {
ChannelHeader(
channelHex = channelHex,
showVideo = false,
showBottomDiviser = false,
sendToChannel = true,
modifier = remember { Modifier.padding(vertical = 5.dp) },
accountViewModel = accountViewModel,
nav = nav
)
val replies = remember { note.replyTo?.toImmutableList() }
val mentions = remember { (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf() }
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
}
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
}
}
}
@@ -2671,14 +2726,14 @@ private fun BadgeBox(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isRepost by remember {
val isRepost by remember(baseNote) {
derivedStateOf {
baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
}
}
if (isRepost) {
val baseReply by remember {
val baseReply by remember(baseNote) {
derivedStateOf {
baseNote.replyTo?.lastOrNull()
}

View File

@@ -24,6 +24,7 @@ import androidx.compose.ui.window.Popup
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -35,6 +36,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
@@ -98,9 +101,7 @@ private fun WatchZapsAndUpdateTallies(
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
pollViewModel.refreshTallies()
}
pollViewModel.refreshTallies()
}
}
@@ -132,8 +133,7 @@ private fun OptionNote(
ZapVote(
baseNote,
poolOption,
accountViewModel,
pollViewModel,
pollViewModel = pollViewModel,
nonClickablePrepend = {
RenderOptionAfterVote(
poolOption.descriptor,
@@ -147,18 +147,21 @@ private fun OptionNote(
)
},
clickablePrepend = {
}
},
accountViewModel = accountViewModel,
nav = nav
)
} else {
ZapVote(
baseNote,
poolOption,
accountViewModel,
pollViewModel,
pollViewModel = pollViewModel,
nonClickablePrepend = {},
clickablePrepend = {
RenderOptionBeforeVote(poolOption.descriptor, canPreview, tags, backgroundColor, accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
nav = nav
)
}
}
@@ -269,11 +272,12 @@ private fun RenderOptionBeforeVote(
fun ZapVote(
baseNote: Note,
poolOption: PollOption,
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
modifier: Modifier = Modifier,
pollViewModel: PollNoteViewModel,
nonClickablePrepend: @Composable () -> Unit,
clickablePrepend: @Composable () -> Unit
clickablePrepend: @Composable () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isLoggedUser by remember {
derivedStateOf {
@@ -282,7 +286,14 @@ fun ZapVote(
}
var wantsToZap by remember { mutableStateOf(false) }
var wantsToPay by remember {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf()
)
}
var zappingProgress by remember { mutableStateOf(0f) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -296,7 +307,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(
@@ -360,6 +371,8 @@ fun ZapVote(
zappingProgress = it
}
},
onPayViaIntent = {
},
zapType = accountViewModel.account.defaultZapType
)
} else {
@@ -384,17 +397,39 @@ fun ZapVote(
onError = {
scope.launch {
zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
showErrorMessageDialog = it
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
},
onPayViaIntent = {
wantsToPay = it
}
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
wantsToPay = persistentListOf()
}
}
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
baseNote.author?.let {
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel))
}
},
onDismiss = { showErrorMessageDialog = null }
)
}
clickablePrepend()
if (poolOption.zappedByLoggedIn) {
@@ -448,7 +483,8 @@ fun FilteredZapAmountChoicePopup(
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
) {
val context = LocalContext.current
@@ -487,6 +523,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
onPayViaIntent,
defaultZapType
)
onDismiss()
@@ -511,6 +548,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
onPayViaIntent,
defaultZapType
)
onDismiss()

View File

@@ -3,13 +3,16 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
@@ -52,12 +55,12 @@ class PollNoteViewModel : ViewModel() {
closedAt = pollEvent?.getTagInt(CLOSED_AT)
}
suspend fun refreshTallies() {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
fun refreshTallies() {
viewModelScope.launch(Dispatchers.Default) {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
_tallies.emit(
pollOptions?.keys?.map {
val newOptions = pollOptions?.keys?.map {
val zappedInOption = zappedPollOptionAmount(it)
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
@@ -71,8 +74,12 @@ class PollNoteViewModel : ViewModel() {
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
} ?: emptyList()
)
}
_tallies.emit(
newOptions ?: emptyList()
)
}
}
fun canZap(): Boolean {

View File

@@ -40,6 +40,7 @@ import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
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
@@ -77,6 +78,7 @@ import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
@@ -106,6 +108,8 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
@@ -210,7 +214,7 @@ private fun InnerReactionRow(
) {
val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) {
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav)
}
}
Log.d("Rendering Metrics", "Reaction Zaps: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
@@ -241,7 +245,7 @@ private fun LoadAndDisplayZapraiser(
wantsToSeeReactions: MutableState<Boolean>,
accountViewModel: AccountViewModel
) {
val zapraiserAmount by remember {
val zapraiserAmount by remember(baseNote) {
derivedStateOf {
baseNote.event?.zapraiserAmount() ?: 0
}
@@ -261,42 +265,26 @@ private fun LoadAndDisplayZapraiser(
}
}
@Immutable
data class ZapraiserStatus(val progress: Float, val left: String)
@Composable
fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) {
val zapsState by baseNote.live().zaps.observeAsState()
var zapraiserProgress by remember { mutableStateOf(0F) }
var zapraiserLeft by remember { mutableStateOf("$zapraiserAmount") }
var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) }
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.calculateZapAmount(it)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (percentage > 1) {
percentage = 1f
}
if (Math.abs(zapraiserProgress - percentage) > 0.001) {
val newZapraiserProgress = percentage
val newZapraiserLeft = if (percentage > 0.99) {
"0"
} else {
showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
}
if (zapraiserLeft != newZapraiserLeft) {
zapraiserLeft = newZapraiserLeft
}
if (zapraiserProgress != newZapraiserProgress) {
zapraiserProgress = newZapraiserProgress
}
zapsState?.note?.let {
accountViewModel.calculateZapraiser(baseNote) { newStatus ->
if (zapraiserStatus != newStatus) {
zapraiserStatus = newStatus
}
}
}
}
val color = if (zapraiserProgress > 0.99) {
val color = if (zapraiserStatus.progress > 0.99) {
DarkerGreen
} else {
MaterialTheme.colors.mediumImportanceLink
@@ -307,7 +295,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
.fillMaxWidth()
.height(if (details) 24.dp else 4.dp),
color = color,
progress = zapraiserProgress
progress = zapraiserStatus.progress
)
if (details) {
@@ -315,14 +303,14 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
contentAlignment = Center,
modifier = TinyBorders
) {
val totalPercentage by remember(zapraiserProgress) {
val totalPercentage by remember(zapraiserStatus) {
derivedStateOf {
"${(zapraiserProgress * 100).roundToInt()}%"
"${(zapraiserStatus.progress * 100).roundToInt()}%"
}
}
Text(
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft),
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left),
modifier = NoSoTinyBorders,
color = MaterialTheme.colors.placeholderText,
fontSize = Font14SP,
@@ -334,15 +322,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
@Composable
private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content: @Composable () -> Unit) {
val hasReactions by baseNote.live().zaps.combineWith(
liveData1 = baseNote.live().boosts,
liveData2 = baseNote.live().reactions,
block = { zapsState, boostsState, reactionsState ->
zapsState?.note?.zaps?.isNotEmpty() == true ||
boostsState?.note?.boosts?.isNotEmpty() == true ||
reactionsState?.note?.reactions?.isNotEmpty() == true
}
).observeAsState(
val hasReactions by baseNote.live().hasReactions.observeAsState(
baseNote.zaps.isNotEmpty() ||
baseNote.boosts.isNotEmpty() ||
baseNote.reactions.isNotEmpty()
@@ -435,17 +415,16 @@ private fun WatchBoostsAndRenderGallery(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostsEvents by remember(boostsState) {
derivedStateOf { baseNote.boosts.toImmutableList() }
}
val boostsEvents by baseNote.live().boostList.observeAsState()
if (boostsEvents.isNotEmpty()) {
RenderBoostGallery(
boostsEvents,
nav,
accountViewModel
)
boostsEvents?.let {
if (it.isNotEmpty()) {
RenderBoostGallery(
it,
nav,
accountViewModel
)
}
}
}
@@ -567,12 +546,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()
}
}
}
}
@@ -676,12 +659,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()
}
}
}
}
@@ -699,6 +692,11 @@ fun BoostReaction(
onQuote = {
wantsToBoost = false
onQuotePress()
},
onRepost = {
scope.launch(Dispatchers.IO) {
accountViewModel.boost(baseNote)
}
}
)
}
@@ -762,6 +760,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
}
}
)
},
@@ -836,10 +848,7 @@ private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountVi
val reactionsState by baseNote.live().reactions.observeAsState()
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
val reactionNote = reactionsState?.note?.getReactionBy(accountViewModel.userProfile())
onNewReactionType(reactionNote)
}
accountViewModel.loadReactionTo(reactionsState?.note, onNewReactionType)
}
}
@@ -874,32 +883,9 @@ private fun RenderReactionType(
@Composable
fun LikeText(baseNote: Note, grayTint: Color) {
val reactionsCount = remember(baseNote) {
mutableStateOf(baseNote.reactions.size)
}
val reactionCount by baseNote.live().reactionCount.observeAsState(0)
val scope = rememberCoroutineScope()
WatchReactionCountForNote(baseNote) { newReactionsCount ->
if (reactionsCount.value != newReactionsCount) {
scope.launch(Dispatchers.Main) {
reactionsCount.value = newReactionsCount
}
}
}
SlidingAnimationCount(reactionsCount, grayTint)
}
@Composable
private fun WatchReactionCountForNote(baseNote: Note, onNewReactionCount: (Int) -> Unit) {
val reactionsState by baseNote.live().reactions.observeAsState()
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
onNewReactionCount(reactionsState?.note?.countReactions() ?: 0)
}
}
SlidingAnimationCount(reactionCount, grayTint)
}
private fun likeClick(
@@ -907,7 +893,8 @@ private fun likeClick(
accountViewModel: AccountViewModel,
scope: CoroutineScope,
context: Context,
onMultipleChoices: () -> Unit
onMultipleChoices: () -> Unit,
onWantsToSignReaction: () -> Unit
) {
if (accountViewModel.account.reactionChoices.isEmpty()) {
scope.launch {
@@ -920,14 +907,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) {
@@ -950,11 +941,18 @@ fun ZapReaction(
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp
animationSize: Dp = 14.dp,
nav: (String) -> Unit
) {
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
var wantsToPay by remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf()
)
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -980,6 +978,15 @@ fun ZapReaction(
},
onMultipleChoices = {
wantsToZap = true
},
onError = {
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = it
}
},
onPayViaIntent = {
wantsToPay = it
}
)
},
@@ -1006,23 +1013,69 @@ fun ZapReaction(
onError = {
scope.launch {
zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
showErrorMessageDialog = it
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
},
onPayViaIntent = {
wantsToPay = it
}
)
}
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
baseNote.author?.let {
scope.launch(Dispatchers.IO) {
val route = routeToMessage(it, showErrorMessageDialog, accountViewModel)
nav(route)
}
}
},
onDismiss = { showErrorMessageDialog = null }
)
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel)
UpdateZapAmountDialog(
onClose = { wantsToChangeZapAmount = false },
accountViewModel = accountViewModel
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
wantsToPay = persistentListOf()
}
}
if (wantsToSetCustomZap) {
ZapCustomDialog({ wantsToSetCustomZap = false }, accountViewModel, baseNote)
ZapCustomDialog(
onClose = { wantsToSetCustomZap = false },
onError = {
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = it
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
},
onPayViaIntent = {
wantsToPay = it
},
accountViewModel = accountViewModel,
baseNote = baseNote
)
}
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
@@ -1055,7 +1108,9 @@ private fun zapClick(
scope: CoroutineScope,
context: Context,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: () -> Unit
onMultipleChoices: () -> Unit,
onError: (String) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
) {
if (accountViewModel.account.zapAmountChoices.isEmpty()) {
scope.launch {
@@ -1067,7 +1122,7 @@ private fun zapClick(
)
.show()
}
} else if (!accountViewModel.isWriteable()) {
} else if (!accountViewModel.isWriteable() && !accountViewModel.loggedInWithExternalSigner()) {
scope.launch {
Toast
.makeText(
@@ -1084,20 +1139,14 @@ private fun zapClick(
null,
"",
context,
onError = {
scope.launch {
onZappingProgress(0f)
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
},
onError = onError,
onProgress = {
scope.launch(Dispatchers.Main) {
onZappingProgress(it)
}
},
zapType = accountViewModel.account.defaultZapType
zapType = accountViewModel.account.defaultZapType,
onPayViaIntent = onPayViaIntent
)
} else if (accountViewModel.account.zapAmountChoices.size > 1) {
onMultipleChoices()
@@ -1139,9 +1188,7 @@ private fun WatchZapsForNote(baseNote: Note, accountViewModel: AccountViewModel,
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
onWasZapped(accountViewModel.calculateIfNoteWasZappedByAccount(baseNote))
}
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, onWasZapped)
}
}
@@ -1171,9 +1218,7 @@ fun WatchZapAmountsForNote(baseNote: Note, accountViewModel: AccountViewModel, o
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
onZapAmount(showAmount(accountViewModel.calculateZapAmount(baseNote)))
}
accountViewModel.calculateZapAmount(baseNote, onZapAmount)
}
}
@@ -1217,7 +1262,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()
}
@@ -1232,8 +1277,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()
}
},
@@ -1412,10 +1462,11 @@ fun ZapAmountChoicePopup(
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapMessage = ""
@@ -1438,6 +1489,7 @@ fun ZapAmountChoicePopup(
context,
onError,
onProgress,
onPayViaIntent,
account.defaultZapType
)
onDismiss()
@@ -1462,6 +1514,7 @@ fun ZapAmountChoicePopup(
context,
onError,
onProgress,
onPayViaIntent,
account.defaultZapType
)
onDismiss()

View File

@@ -11,17 +11,16 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
@@ -29,80 +28,40 @@ import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconButtonModifie
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
public fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
var showShowMore by remember { mutableStateOf(false) }
var lazyRelayList by remember {
val baseNumber = baseNote.relays.map {
it.removePrefix("wss://").removePrefix("ws://")
}.toImmutableList()
mutableStateOf(baseNumber)
}
var shortRelayList by remember {
mutableStateOf(lazyRelayList.take(3).toImmutableList())
}
val scope = rememberCoroutineScope()
WatchRelayLists(baseNote) { relayList ->
if (!equalImmutableLists(relayList, lazyRelayList)) {
scope.launch(Dispatchers.Main) {
lazyRelayList = relayList
shortRelayList = relayList.take(3).toImmutableList()
}
}
val nextShowMore = relayList.size > 3
if (nextShowMore != showShowMore) {
scope.launch(Dispatchers.Main) {
// only triggers recomposition when actually different
showShowMore = nextShowMore
}
val relayList by baseNote.live().relayInfo.observeAsState(persistentListOf())
val shortRelayList by remember {
derivedStateOf {
relayList.take(3).toImmutableList()
}
}
Spacer(DoubleVertSpacer)
if (expanded) {
VerticalRelayPanelWithFlow(lazyRelayList, accountViewModel, nav)
VerticalRelayPanelWithFlow(relayList, accountViewModel, nav)
} else {
VerticalRelayPanelWithFlow(shortRelayList, accountViewModel, nav)
}
if (showShowMore && !expanded) {
if (relayList.size > 3 && !expanded) {
ShowMoreRelaysButton {
expanded = true
}
}
}
@Composable
private fun WatchRelayLists(baseNote: Note, onListChanges: (ImmutableList<String>) -> Unit) {
val noteRelaysState by baseNote.live().relays.observeAsState()
LaunchedEffect(key1 = noteRelaysState) {
launch(Dispatchers.IO) {
val relayList = noteRelaysState?.note?.relays?.map {
it.removePrefix("wss://").removePrefix("ws://")
} ?: emptyList()
onListChanges(relayList.toImmutableList())
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
@Stable
private fun VerticalRelayPanelWithFlow(
relays: ImmutableList<String>,
relays: ImmutableList<RelayBriefInfo>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -17,7 +18,6 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -29,13 +29,15 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.actions.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
@@ -44,6 +46,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Composable
public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
@@ -59,15 +63,13 @@ public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewMo
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderRelayList(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteRelays by baseNote.live().relays.map {
it.note.relays
}.observeAsState(baseNote.relays)
val noteRelays by baseNote.live().relayInfo.observeAsState()
FlowRow(StdStartPadding) {
val relaysToDisplay = remember(noteRelays, expanded.value) {
if (expanded.value) noteRelays else noteRelays.take(3)
if (expanded.value) noteRelays else noteRelays?.take(3)?.toImmutableList()
}
relaysToDisplay.forEach {
relaysToDisplay?.forEach {
RenderRelay(it, accountViewModel, nav)
}
}
@@ -104,14 +106,7 @@ fun ChatRelayExpandButton(onClick: () -> Unit) {
}
@Composable
fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val iconUrl by remember(dirtyUrl) {
derivedStateOf {
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
"https://$cleanUrl/favicon.ico"
}
}
fun RenderRelay(relay: RelayBriefInfo, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
if (relayInfo != null) {
@@ -120,8 +115,9 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
relayInfo = null
},
relayInfo = relayInfo!!,
accountViewModel,
nav
relayBriefInfo = relay,
accountViewModel = accountViewModel,
nav = nav
)
}
@@ -130,7 +126,7 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
val interactionSource = remember { MutableInteractionSource() }
val ripple = rememberRipple(bounded = false, radius = Size15dp)
val clickableModifier = remember(dirtyUrl) {
val clickableModifier = remember(relay) {
Modifier
.padding(1.dp)
.size(Size15dp)
@@ -139,9 +135,30 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
interactionSource = interactionSource,
indication = ripple,
onClick = {
loadRelayInfo(dirtyUrl, context, scope) {
relayInfo = it
}
accountViewModel.retrieveRelayDocument(
relay.url,
onInfo = {
relayInfo = it
},
onError = { url, errorCode, exceptionMessage ->
val msg = when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
}
scope.launch {
Toast
.makeText(
context,
msg,
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
)
}
@@ -149,17 +166,17 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
Box(
modifier = clickableModifier
) {
RenderRelayIcon(iconUrl)
RenderRelayIcon(relay.favIcon)
}
}
@Composable
private fun RenderRelayIcon(iconUrl: String) {
fun RenderRelayIcon(iconUrl: String, size: Dp = Size13dp) {
val backgroundColor = MaterialTheme.colors.background
val iconModifier = remember {
Modifier
.size(Size13dp)
.size(size)
.clip(shape = CircleShape)
.background(backgroundColor)
}

View File

@@ -197,7 +197,11 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel) {
fun UpdateZapAmountDialog(
onClose: () -> Unit,
nip47uri: String? = null,
accountViewModel: AccountViewModel
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()

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
@@ -73,9 +75,7 @@ fun NoteAuthorPicture(
modifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val author by baseNote.live().metadata.map {
it.note.author
}.distinctUntilChanged().observeAsState(baseNote.author)
val author by baseNote.live().authorChanges.observeAsState(baseNote.author)
Crossfade(targetState = author) {
if (it == null) {
@@ -104,6 +104,32 @@ fun DisplayBlankAuthor(size: Dp, modifier: Modifier = Modifier) {
)
}
@Composable
fun UserPicture(
userHex: String,
size: Dp,
pictureModifier: Modifier = remember { Modifier },
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadUser(baseUserHex = userHex) {
if (it != null) {
UserPicture(
user = it,
size = size,
pictureModifier = pictureModifier,
accountViewModel = accountViewModel,
nav = nav
)
} else {
DisplayBlankAuthor(
size,
pictureModifier
)
}
}
}
@Composable
fun UserPicture(
user: User,
@@ -112,23 +138,19 @@ fun UserPicture(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val route by remember {
val route by remember(user) {
derivedStateOf {
"User/${user.pubkeyHex}"
}
}
val scope = rememberCoroutineScope()
ClickableUserPicture(
baseUser = user,
size = size,
accountViewModel = accountViewModel,
modifier = pictureModifier,
onClick = {
scope.launch {
nav(route)
}
nav(route)
}
)
}
@@ -274,9 +296,7 @@ fun InnerBaseUserPicture(
accountViewModel: AccountViewModel,
modifier: Modifier
) {
val userProfile by baseUser.live().metadata.map {
it.user.profilePicture()
}.distinctUntilChanged().observeAsState(baseUser.profilePicture())
val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture())
PictureAndFollowingMark(
userHex = baseUser.pubkeyHex,
@@ -391,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()
@@ -446,36 +470,104 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
Text(stringResource(R.string.quick_action_share))
}
Divider()
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePrivateBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
} else {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPrivateBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (state.isPublicBookmarkNote) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePublicBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
} else {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPublicBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.add_to_public_bookmarks))
}
}
Divider()
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.broadcast(note); onDismiss() } }) {
Text(stringResource(R.string.broadcast))
}
Divider()
if (state.isLoggedUser) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) {
Text(stringResource(R.string.request_deletion))
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
if (accountViewModel.loggedInWithExternalSigner()) {
val bookmarks = accountViewModel.userProfile().latestBookmarkList
ExternalSignerUtils.decrypt(
bookmarks?.content ?: "",
accountViewModel.account.keyPair.pubKey.toHexKey(),
bookmarks?.id ?: ""
)
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
accountViewModel.removePrivateBookmark(note, bookmarks?.decryptedContent ?: "")
} else {
accountViewModel.removePrivateBookmark(note)
onDismiss()
}
}
}
) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
} else {
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
Text(stringResource(R.string.block_report))
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
if (accountViewModel.loggedInWithExternalSigner()) {
val bookmarks = accountViewModel.userProfile().latestBookmarkList
ExternalSignerUtils.decrypt(
bookmarks?.content ?: "",
accountViewModel.account.keyPair.pubKey.toHexKey(),
bookmarks?.id ?: ""
)
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
accountViewModel.addPrivateBookmark(note, bookmarks?.decryptedContent ?: "")
} else {
accountViewModel.addPrivateBookmark(note)
onDismiss()
}
}
}
) {
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (state.isPublicBookmarkNote) {
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
if (accountViewModel.loggedInWithExternalSigner()) {
val bookmarks = accountViewModel.userProfile().latestBookmarkList
ExternalSignerUtils.decrypt(
bookmarks?.content ?: "",
accountViewModel.account.keyPair.pubKey.toHexKey(),
bookmarks?.id ?: ""
)
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
accountViewModel.removePublicBookmark(
note,
bookmarks?.decryptedContent ?: ""
)
} else {
accountViewModel.removePublicBookmark(note)
onDismiss()
}
}
}
) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
} else {
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
if (accountViewModel.loggedInWithExternalSigner()) {
val bookmarks = accountViewModel.userProfile().latestBookmarkList
ExternalSignerUtils.decrypt(
bookmarks?.content ?: "",
accountViewModel.account.keyPair.pubKey.toHexKey(),
bookmarks?.id ?: ""
)
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
accountViewModel.addPublicBookmark(
note,
bookmarks?.decryptedContent ?: ""
)
} else {
accountViewModel.addPublicBookmark(note)
onDismiss()
}
}
}
) {
Text(stringResource(R.string.add_to_public_bookmarks))
}
}
Divider()
@@ -494,6 +586,16 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
Text(stringResource(R.string.content_warning_see_warnings))
}
}
Divider()
if (state.isLoggedUser) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) {
Text(stringResource(R.string.request_deletion))
}
} else {
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
Text(stringResource(R.string.block_report))
}
}
}
if (reportDialogShowing) {
@@ -508,9 +610,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
fun WatchBookmarksFollowsAndAccount(note: Note, accountViewModel: AccountViewModel, onNew: (DropDownParams) -> Unit) {
val followState by accountViewModel.userProfile().live().follows.observeAsState()
val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState()
val showSensitiveContent by accountViewModel.accountLiveData.map {
it.account.showSensitiveContent
}.distinctUntilChanged().observeAsState(accountViewModel.account.showSensitiveContent)
val showSensitiveContent by accountViewModel.showSensitiveContentChanges.observeAsState(accountViewModel.account.showSensitiveContent)
LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
launch(Dispatchers.IO) {

View File

@@ -1,35 +1,62 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ZapOptionstViewModel : ViewModel() {
private var account: Account? = null
@@ -58,17 +85,21 @@ class ZapOptionstViewModel : ViewModel() {
}
@Composable
fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, baseNote: Note) {
fun ZapCustomDialog(
onClose: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
accountViewModel: AccountViewModel,
baseNote: Note
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: ZapOptionstViewModel = viewModel()
LaunchedEffect(accountViewModel) {
postViewModel.load(accountViewModel.account)
}
var zappingProgress by remember { mutableStateOf(0f) }
val zapTypes = listOf(
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
@@ -108,21 +139,11 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
null,
postViewModel.customMessage.text,
context,
onError = {
zappingProgress = 0f
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT).show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
},
onError = onError,
onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
zapType = selectedZapType
)
onClose()
}
}
@@ -225,3 +246,163 @@ fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
Text(text = "⚡Zap ", color = Color.White)
}
}
@Composable
fun ErrorMessageDialog(
title: String,
textContent: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickStartMessage: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(title)
},
text = {
Text(textContent)
},
buttons = {
Row(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onClickStartMessage) {
Icon(
painter = painterResource(R.drawable.ic_dm),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_talk_to_user))
}
Button(onClick = onDismiss, colors = buttonColors) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Done,
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_button_ok))
}
}
}
}
)
}
@Composable
fun PayViaIntentDialog(
payingInvoices: ImmutableList<ZapPaymentHandler.Payable>,
accountViewModel: AccountViewModel,
onClose: () -> Unit
) {
val context = LocalContext.current
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onPress = onClose)
}
Spacer(modifier = DoubleVertSpacer)
payingInvoices.forEachIndexed { index, it ->
val paid = remember {
mutableStateOf(false)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
if (it.user != null) {
BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel)
} else {
DisplayBlankAuthor(size = Size55dp)
}
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
if (it.user != null) {
UsernameDisplay(it.user, showPlayButton = false)
} else {
Text(
text = stringResource(id = R.string.wallet_number, index + 1),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row() {
Text(
text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Spacer(modifier = StdHorzSpacer)
Text(
text = stringResource(id = R.string.sats),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
}
Spacer(modifier = DoubleHorzSpacer)
PayButton(isActive = !paid.value) {
paid.value = true
val uri = "lightning:" + it.invoice
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
}
}
}
}
}
}
}
@Composable
fun PayButton(isActive: Boolean, modifier: Modifier = Modifier, onPost: () -> Unit = {}) {
Button(
modifier = modifier,
onClick = {
onPost()
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
),
contentPadding = PaddingValues(0.dp)
) {
if (isActive) {
Text(text = stringResource(R.string.pay), color = Color.White)
} else {
Text(text = stringResource(R.string.paid), color = Color.White)
}
}
}

View File

@@ -173,6 +173,8 @@ fun UserActionOptions(
baseAuthor: User,
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
WatchIsHiddenUser(baseAuthor, accountViewModel) { isHidden ->
if (isHidden) {
ShowUserButton {
@@ -209,14 +211,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 +235,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

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

@@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -102,11 +101,9 @@ private fun WatchScrollToTop(
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
launch {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}

View File

@@ -34,7 +34,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@Stable
@@ -270,7 +269,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val bundler = BundledUpdate(1000, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO)
@OptIn(ExperimentalTime::class)
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
@@ -282,7 +280,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun invalidateDataAndSendToTop() {
clear()
bundler.invalidate(false) {
@@ -296,7 +293,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
clear()
@@ -312,7 +308,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
val newObjects = it.flatten().toSet()

View File

@@ -95,10 +95,10 @@ class NostrDiscoverChatFeedViewModel(val account: Account) : FeedViewModel(Disco
}
}
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
class Factory(val noteId: String) : ViewModelProvider.Factory {
class NostrThreadFeedViewModel(account: Account, noteId: String) : FeedViewModel(ThreadFeedFilter(account, noteId)) {
class Factory(val account: Account, val noteId: String) : ViewModelProvider.Factory {
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
return NostrThreadFeedViewModel(noteId) as NostrThreadFeedViewModel
return NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel
}
}
}
@@ -236,7 +236,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
checkNotInMainThread()
lastFeedKey = localFilter.feedKey()
val notes = localFilter.loadTop().toImmutableList()
val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList()
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
@@ -301,11 +301,13 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
}
}
}

View File

@@ -1,20 +0,0 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.relays.RelayPool
@Stable
class RelayPoolViewModel : ViewModel() {
val connectionStatus = RelayPool.live.map {
val connectedRelays = it.relays.connectedRelays()
val availableRelays = it.relays.availableRelays()
"$connectedRelays/$availableRelays"
}.distinctUntilChanged()
val isConnected = RelayPool.live.map {
it.relays.connectedRelays() > 0
}.distinctUntilChanged()
}

View File

@@ -262,6 +262,7 @@ fun NoteMaster(
HiddenNote(
reports,
note.author?.let { account.isHidden(it) } ?: false,
accountViewModel,
Modifier,
false,

View File

@@ -1,37 +1,47 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
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.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.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.util.Locale
@@ -45,22 +55,26 @@ class AccountViewModel(val account: Account) : ViewModel() {
val userFollows: LiveData<UserState> = account.userProfile().live().follows.map { it }
val userRelays: LiveData<UserState> = account.userProfile().live().relays.map { it }
val discoveryListLiveData = accountLiveData.map {
val discoveryListLiveData = account.live.map {
it.account.defaultDiscoveryFollowList
}.distinctUntilChanged()
val homeListLiveData = accountLiveData.map {
val homeListLiveData = account.live.map {
it.account.defaultHomeFollowList
}.distinctUntilChanged()
val notificationListLiveData = accountLiveData.map {
val notificationListLiveData = account.live.map {
it.account.defaultNotificationFollowList
}.distinctUntilChanged()
val storiesListLiveData = accountLiveData.map {
val storiesListLiveData = account.live.map {
it.account.defaultStoriesFollowList
}.distinctUntilChanged()
val showSensitiveContentChanges = account.live.map {
it.account.showSensitiveContent
}.distinctUntilChanged()
fun updateAutomaticallyStartPlayback(
automaticallyStartPlayback: ConnectivityType
) {
@@ -83,6 +97,10 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.isWriteable()
}
fun loggedInWithExternalSigner(): Boolean {
return account.loginWithExternalSigner
}
fun userProfile(): User {
return account.userProfile()
}
@@ -121,81 +139,93 @@ class AccountViewModel(val account: Account) : ViewModel() {
account.delete(account.boostsTo(note))
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
return account.calculateIfNoteWasZappedByAccount(zappedNote)
fun calculateIfNoteWasZappedByAccount(zappedNote: Note, onWasZapped: (Boolean) -> Unit) {
viewModelScope.launch(Dispatchers.Default) {
onWasZapped(account.calculateIfNoteWasZappedByAccount(zappedNote))
}
}
fun calculateZapAmount(zappedNote: Note): BigDecimal {
suspend fun calculateZapAmount(zappedNote: Note): BigDecimal {
return account.calculateZappedAmount(zappedNote)
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
fun calculateZapAmount(zappedNote: Note, onZapAmount: (String) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
innerZap(note, amount, pollOption, message, context, onError, onProgress, zapType)
onZapAmount(showAmount(account.calculateZappedAmount(zappedNote)))
}
}
suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
fun calculateZapraiser(zappedNote: Note, onZapraiserStatus: (ZapraiserStatus) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0
val newZapAmount = calculateZapAmount(zappedNote)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (lud16.isNullOrBlank()) {
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
return
if (percentage > 1) {
percentage = 1f
}
val newZapraiserProgress = percentage
val newZapraiserLeft = if (percentage > 0.99) {
"0"
} else {
showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
}
onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft))
}
}
var zapRequestJson = ""
fun decryptAmountMessage(
zapRequest: Note,
zapEvent: Note?,
onNewState: (ZapAmountCommentNotification?) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
onNewState(innerDecryptAmountMessage(zapRequest, zapEvent))
}
}
if (zapType != LnZapEvent.ZapType.NONZAP) {
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType)
if (zapRequest != null) {
zapRequestJson = zapRequest.toJson()
private suspend fun innerDecryptAmountMessage(
zapRequest: Note,
zapEvent: Note?
): ZapAmountCommentNotification? {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
return ZapAmountCommentNotification(
newAuthor,
decryptedContent.content.ifBlank { null },
showAmountAxis(amount)
)
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
return ZapAmountCommentNotification(
zapRequest.author,
zapRequest.event?.content()?.ifBlank { null },
showAmountAxis(amount)
)
}
}
}
return null
}
onProgress(0.10f)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
response.error?.message
?: response.error?.code?.toString()
?: "Error parsing error message"
)
} else {
onProgress(1f)
}
}
)
onProgress(0.8f)
// Awaits for the event to come back to LocalCache.
viewModelScope.launch(Dispatchers.IO) {
delay(5000)
onProgress(0f)
}
} else {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
ContextCompat.startActivity(context, intent, null)
}
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress
)
fun zap(
note: Note,
amount: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
zapType: LnZapEvent.ZapType
) {
viewModelScope.launch(Dispatchers.IO) {
ZapPaymentHandler(account).zap(note, amount, pollOption, message, context, onError, onProgress, onPayViaIntent, zapType)
}
}
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
@@ -225,10 +255,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)
}
@@ -331,25 +377,44 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.defaultZapType
}
fun isNoteAcceptable(note: Note, onReady: (Boolean, Boolean, ImmutableSet<Note>) -> Unit) {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
@Immutable
data class NoteComposeReportState(
val isAcceptable: Boolean = true,
val canPreview: Boolean = true,
val isHiddenAuthor: Boolean = false,
val relevantReports: ImmutableSet<Note> = persistentSetOf()
)
if (isFromLoggedIn || isFromLoggedInFollow) {
// No need to process if from trusted people
onReady(true, true, persistentSetOf())
} else {
val newCanPreview = !note.hasAnyReports()
fun isNoteAcceptable(note: Note, onReady: (NoteComposeReportState) -> Unit) {
viewModelScope.launch {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
val newIsAcceptable = account.isAcceptable(note)
if (newCanPreview && newIsAcceptable) {
// No need to process reports if nothing is wrong
onReady(true, true, persistentSetOf())
if (isFromLoggedIn || isFromLoggedInFollow) {
// No need to process if from trusted people
onReady(NoteComposeReportState(true, true, false, persistentSetOf()))
} else if (note.author?.let { account.isHidden(it) } == true) {
onReady(NoteComposeReportState(false, false, true, persistentSetOf()))
} else {
val newRelevantReports = account.getRelevantReports(note)
val newCanPreview = !note.hasAnyReports()
onReady(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
val newIsAcceptable = account.isAcceptable(note)
if (newCanPreview && newIsAcceptable) {
// No need to process reports if nothing is wrong
onReady(NoteComposeReportState(true, true, false, persistentSetOf()))
} else {
val newRelevantReports = account.getRelevantReports(note)
onReady(
NoteComposeReportState(
newIsAcceptable,
newCanPreview,
false,
newRelevantReports.toImmutableSet()
)
)
}
}
}
}
@@ -380,11 +445,82 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
fun createStatus(newStatus: String) {
account.createStatus(newStatus)
viewModelScope.launch(Dispatchers.IO) {
account.createStatus(newStatus)
}
}
fun updateStatus(it: AddressableNote, newStatus: String) {
account.updateStatus(it, newStatus)
viewModelScope.launch(Dispatchers.IO) {
account.updateStatus(it, newStatus)
}
}
fun deleteStatus(it: AddressableNote) {
viewModelScope.launch(Dispatchers.IO) {
account.deleteStatus(it)
}
}
fun checkIfOnline(url: String, onResult: (Boolean) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val isOnline = OnlineChecker.isOnline(url)
onResult(isOnline)
}
}
fun urlPreview(url: String, onResult: suspend (UrlPreviewState) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url, onResult)
}
}
fun loadReactionTo(note: Note?, onNewReactionType: (String?) -> Unit) {
if (note == null) return
viewModelScope.launch(Dispatchers.Default) {
onNewReactionType(note.getReactionBy(userProfile()))
}
}
fun verifyNip05(userMetadata: UserMetadata, pubkeyHex: String, onResult: (Boolean) -> Unit) {
val nip05 = userMetadata.nip05?.ifBlank { null } ?: return
viewModelScope.launch(Dispatchers.IO) {
Nip05Verifier().verifyNip05(
nip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
userMetadata.nip05Verified = true
userMetadata.nip05LastVerificationTime = TimeUtils.now()
onResult(userMetadata.nip05Verified)
} else {
userMetadata.nip05Verified = false
userMetadata.nip05LastVerificationTime = 0
onResult(userMetadata.nip05Verified)
}
},
onError = {
userMetadata.nip05LastVerificationTime = 0
userMetadata.nip05Verified = false
onResult(userMetadata.nip05Verified)
}
)
}
}
fun retrieveRelayDocument(
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
Nip11CachedRetriever.loadRelayInfo(dirtyUrl, onInfo, onError)
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {

View File

@@ -623,7 +623,7 @@ fun ShowVideoStreaming(
}
url?.let {
CheckIfUrlIsOnline(url) {
CheckIfUrlIsOnline(url, accountViewModel) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
@@ -893,7 +893,7 @@ private fun ShortChannelActionOptions(
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
Spacer(modifier = StdHorzSpacer)
}
}
@@ -968,7 +968,7 @@ private fun LiveChannelActionOptions(
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
}
}

View File

@@ -101,6 +101,7 @@ import kotlinx.coroutines.withContext
@Composable
fun ChatroomScreen(
roomId: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -110,6 +111,7 @@ fun ChatroomScreen(
it?.let {
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
accountViewModel = accountViewModel,
nav = nav
)
@@ -120,6 +122,7 @@ fun ChatroomScreen(
@Composable
fun ChatroomScreenByAuthor(
authorPubKeyHex: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -129,6 +132,7 @@ fun ChatroomScreenByAuthor(
it?.let {
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
accountViewModel = accountViewModel,
nav = nav
)
@@ -166,7 +170,12 @@ fun LoadRoomByAuthor(authorPubKeyHex: String, accountViewModel: AccountViewModel
}
@Composable
fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun PrepareChatroomViewModels(
room: ChatroomKey,
draftMessage: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val feedViewModel: NostrChatroomFeedViewModel = viewModel(
key = room.hashCode().toString() + "ChatroomViewModels",
factory = NostrChatroomFeedViewModel.Factory(
@@ -193,6 +202,12 @@ fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewMo
}
}
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) {
newPostModel.message = TextFieldValue(draftMessage)
}
}
ChatroomScreen(
room = room,
feedViewModel = feedViewModel,

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

@@ -29,7 +29,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
@@ -42,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@@ -136,12 +134,14 @@ private fun HomePages(
}
@Composable
fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) {
fun CheckIfUrlIsOnline(url: String, accountViewModel: AccountViewModel, whenOnline: @Composable () -> Unit) {
var online by remember { mutableStateOf(false) }
LaunchedEffect(key1 = url) {
launch(Dispatchers.IO) {
online = OnlineChecker.isOnline(url)
accountViewModel.checkIfOnline(url) { isOnline ->
if (online != isOnline) {
online = isOnline
}
}
}
@@ -162,11 +162,9 @@ fun WatchAccountForHomeScreen(
val followState by accountViewModel.account.userProfile().live().follows.observeAsState()
LaunchedEffect(accountViewModel, accountState?.account?.defaultHomeFollowList, followState) {
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
}
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
}
}

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