Compare commits

...

471 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
Vitor Pamplona
30391160da v0.75.2 2023-08-24 18:56:39 -04:00
Vitor Pamplona
3273aa32a5 Removes the debug r tag used to test. 2023-08-24 18:54:48 -04:00
Vitor Pamplona
1e358d31d5 v0.75.1 2023-08-24 17:59:26 -04:00
Vitor Pamplona
b57dd98059 Displays links in the Status if they are present. 2023-08-24 17:58:34 -04:00
Vitor Pamplona
5b596d2a56 - Updating Status on the Drawer
- Reducing font size for statuses and nip05s
2023-08-24 17:34:02 -04:00
Vitor Pamplona
62db68c7b2 Correctly display status 2023-08-24 16:17:20 -04:00
Vitor Pamplona
b3bb85cd53 Display statuses if NIP05 succeeds. 2023-08-24 15:57:18 -04:00
Vitor Pamplona
fe6fb8470d Adds isExpired method in events. 2023-08-24 15:56:43 -04:00
Vitor Pamplona
ee97eac00c Downloads Statuses from Relays 2023-08-24 15:56:08 -04:00
Vitor Pamplona
48e3ccc165 Allow changing message status 2023-08-24 15:55:32 -04:00
Vitor Pamplona
52c0503b6d Support for pruning expired events. 2023-08-24 13:13:30 -04:00
Vitor Pamplona
c052399a16 Adds support to consume Status Events 2023-08-24 13:12:10 -04:00
Vitor Pamplona
76faf7ae35 Moving livedata creation to long-lived objects 2023-08-24 12:53:07 -04:00
Vitor Pamplona
36ea33a919 Adds StatusEvent support 2023-08-24 08:56:51 -04:00
Vitor Pamplona
8aad4d8030 Moves Manifest to target API 34 2023-08-24 08:56:37 -04:00
Vitor Pamplona
a23c8c5c04 v0.75.0 2023-08-23 18:22:38 -04:00
Vitor Pamplona
0434ef6ec6 Fixing quotes and boosts for NIP94 and NIP95 content 2023-08-23 17:31:11 -04:00
Vitor Pamplona
4d85a2f6fb Correct imePadding of the NewPostView 2023-08-23 17:08:46 -04:00
Vitor Pamplona
e9b235f748 Don't show zaps on search. 2023-08-23 16:27:04 -04: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
Vitor Pamplona
30320805da Adds Thai translation 2023-08-23 10:19:00 -04: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
Vitor Pamplona
8e913ef974 Adds replies and boosts to Stories 2023-08-22 17:49:44 -04:00
Vitor Pamplona
eb191406e6 Vertical Arrangement not working as intended. 2023-08-22 17:32:59 -04:00
Vitor Pamplona
ca89b70150 Activates pruning of old replaceable versions 2023-08-22 17:07:59 -04:00
Vitor Pamplona
6bcd6950ef Refactors replaceable consumers into a single function
Activates calendar consumers
2023-08-22 16:59:58 -04:00
Vitor Pamplona
dfa9c77e3e Migrates Addressable Events to a Base class 2023-08-22 16:39:23 -04:00
Vitor Pamplona
af143f2ea1 Base support for Calendar events 2023-08-22 16:19:59 -04:00
Vitor Pamplona
52761b3901 Activates pull to refresh on the Stories feed. 2023-08-22 16:19:37 -04:00
Vitor Pamplona
f027d23fcf Small refactoring to create the PrivateZap Event 2023-08-22 16:10:32 -04:00
Vitor Pamplona
2d30f3e9d2 Finishing the prune clean up to make sure we don't delete post from other accounts logged in. 2023-08-22 16:01:59 -04:00
Vitor Pamplona
a63b383425 BugFix Force Relay Reconnection when writing a new events. 2023-08-22 14:06:02 -04:00
Vitor Pamplona
e04eb733e7 - Protecting contact lists of all accounts in the device.
- Pruning events that are not from or cite an account.
2023-08-22 14:04:49 -04:00
Vitor Pamplona
0a49298ec2 Faster ReactionRow compositions 2023-08-22 12:23:45 -04:00
Vitor Pamplona
1121cfcaf9 BugFix on bundled update dispatchers. 2023-08-22 12:23:26 -04:00
Vitor Pamplona
66b5fad6c2 Saves some loading time by moving the check if notification was already asked forward. 2023-08-22 11:23:11 -04:00
Vitor Pamplona
5d352f1b6c Updates compose navigation, exoplayer and vico dependencies to the latest. 2023-08-22 11:16:31 -04:00
Vitor Pamplona
9018175f54 Removes unecessary import. 2023-08-22 11:06:10 -04:00
Vitor Pamplona
98c55661e8 Migrates to Compose 1.5.0 2023-08-22 11:05:17 -04:00
Vitor Pamplona
521407f879 Updates Gradle plugin 2023-08-22 10:07:40 -04:00
Vitor Pamplona
fa4257ad7d Separating NIP24 into the encryption NIP 44 and the messaging NIP 24 2023-08-22 09:41:40 -04:00
Vitor Pamplona
d73dec8f0b v0.74.5 2023-08-21 18:31:00 -04:00
Vitor Pamplona
c6124376df Merge pull request #552 from greenart7c3/fix-crash
fix crash when getting firebase token without play services
2023-08-21 18:30:02 -04:00
Vitor Pamplona
87c19f4232 BugFix for ClassCastException when the event changes 2023-08-21 18:21:28 -04:00
greenart7c3
a1f116909f fix crash when not using play services 2023-08-21 19:20:14 -03:00
Vitor Pamplona
ca9951f9b8 Fixes the loading of GiftWraps as intents in the app. 2023-08-21 18:19:10 -04:00
Vitor Pamplona
2bedd79e6f Fixing search for root to go backwards in the etag-stack. 2023-08-21 18:17:21 -04:00
Vitor Pamplona
a9af2d8982 v0.74.4 2023-08-21 17:30:37 -04:00
Vitor Pamplona
655a563f61 Moves community description and rules to a single Translateable text 2023-08-21 16:09:22 -04:00
Vitor Pamplona
9777c709a1 - Fixes some of the TopBar padding issues.
- Adds more info tap on single-user chat rooms
- Moves creation date to a new line.
2023-08-21 15:42:45 -04:00
greenart7c3
453e63025d fix crash in PushNotificationUtils when theres no google play services 2023-08-21 15:58:01 -03:00
Vitor Pamplona
251f4854fc Copies to Clipboard the GiftWrap ID and not the inner message ID 2023-08-21 14:23:36 -04:00
greenart7c3
17ecc98e1e Merge branch 'main' into amber 2023-08-21 14:59:20 -03:00
Vitor Pamplona
43c32f0b0e Moves last scope to a class-bound variable. 2023-08-21 13:51:03 -04:00
greenart7c3
f9272f5cfc implemented amber support for auth event and the notifications 2023-08-21 14:48:11 -03:00
Vitor Pamplona
ec3b07147c Avoids creating new corotine scopes 2023-08-21 13:34:18 -04: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
Vitor Pamplona
0e6a2c339e Fixes the update of the feed after adding an image on stories. 2023-08-20 21:24:57 -04:00
Vitor Pamplona
844f7cd478 Reports on the number of observers to the cache. 2023-08-20 21:24:23 -04:00
Vitor Pamplona
a2edc9f3b2 Fixes the divider presence in Live Activities and Notifications 2023-08-20 17:31:36 -04:00
Vitor Pamplona
35d09aee77 - Filter follows in the list of participants
- Uses KIND3 follows for the notification dot in the bottom nav bar
2023-08-20 17:08:24 -04:00
Vitor Pamplona
a79d1d5bd6 Fixes issue with unfollowing hashtag written in a different case 2023-08-20 16:46:44 -04:00
Vitor Pamplona
ba9315fcd1 Moves coroutines of BundledUpdate and BundledInsert to a managed model. 2023-08-20 16:37:49 -04:00
Vitor Pamplona
f5c71b4f90 Broadcasts a note when a person replies to it. 2023-08-20 16:35:34 -04:00
Vitor Pamplona
5a40b39952 v0.74.3 2023-08-20 11:36:00 -04:00
Vitor Pamplona
2ac3b42d4d Correctly gets the Global Relays being used. 2023-08-20 11:33:56 -04:00
Vitor Pamplona
7cfad373f2 Adds Copy URL and Copy Note ID popups for images and videos 2023-08-20 11:18:31 -04:00
Vitor Pamplona
5d061a79e2 Explanation on why the Global and All Follows code have spaces 2023-08-20 10:29:35 -04:00
Vitor Pamplona
eb59a9d505 Keeps track of a message's GiftWrap host in order to broadcast later. 2023-08-20 10:24:11 -04:00
Vitor Pamplona
784f867a8e Updating media controls to the next version 2023-08-20 01:16:08 -04:00
Vitor Pamplona
954a9d66fb Fixes the crash when opening a malformed thread with a wrong root marker 2023-08-20 01:15:46 -04:00
Vitor Pamplona
dfdced3e8c Includes the zaps from the user's own key in the Summary Chart 2023-08-19 14:57:24 -04:00
Vitor Pamplona
0af141c5b7 Fixes generateForegroundServiceDidNotStartInTimeException on Samsung phones 2023-08-19 14:44:39 -04:00
Vitor Pamplona
70c4c69bbf v0.74.2 2023-08-19 12:40:26 -04:00
Vitor Pamplona
5597189ea5 Allowing null values as a result of the mapper 2023-08-19 12:38:44 -04:00
Vitor Pamplona
8884adcefb Testing new build to find bug on Account loading: 0.74.1 2023-08-19 11:58:08 -04:00
Vitor Pamplona
92be03ca51 v0.74.0 2023-08-19 11:16:13 -04:00
Vitor Pamplona
b4f9103b62 refacctoring 2023-08-19 11:14:10 -04:00
Vitor Pamplona
63f3a6cdf9 Implements contextual rounded corners for images and videos 2023-08-19 11:08:55 -04:00
Vitor Pamplona
d8ce3b9d37 Migrates to prefixes of 8 because more relays seem to work with this 2023-08-18 23:43:16 -04:00
Vitor Pamplona
2ac5742b0e Adds a waveform to audio files 2023-08-18 23:42:24 -04:00
Vitor Pamplona
58dd69091e Adds support for Stemstr 2023-08-18 20:36:32 -04:00
Vitor Pamplona
7a93f9956f Updating SDK to the latest 2023-08-18 19:32:30 -04:00
Vitor Pamplona
8dbded0185 Single method to test performance of receiving a GiftWrap. 2023-08-18 11:52:54 -04:00
Vitor Pamplona
41b93e3860 Caches shared key for performance. 2023-08-18 10:55:28 -04:00
Vitor Pamplona
2f741779fa Improves BechUtils performance. 2023-08-18 10:55:06 -04: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
Vitor Pamplona
918273d825 showing debug when pausing the app. 2023-08-17 17:08:02 -04:00
Vitor Pamplona
eef534617c Fixing ThreadAssembler with two roots 2023-08-17 15:15:13 -04:00
Vitor Pamplona
4e89b1494d Removes experimental time from ThreadAssembler 2023-08-17 10:23:18 -04:00
Vitor Pamplona
be1ae35a60 Fixing Proguard from latest refactoring 2023-08-17 10:23:00 -04:00
Vitor Pamplona
47395465bb Automatically activates NIP24 chats when users have received NIP24 messages from the counterparty 2023-08-16 21:16:32 -04:00
Vitor Pamplona
c49f89f711 Fixes the color of the chart for changes in the Apps theme in settings 2023-08-16 21:03:50 -04:00
Vitor Pamplona
6721c83266 Back button was too bright. 2023-08-16 18:28:08 -04:00
Vitor Pamplona
ce27be2053 Fixes room names when clients send the same user twice in the ptags. 2023-08-16 18:10:56 -04:00
Vitor Pamplona
fee2a0e4f8 Fixes an incorrect import. 2023-08-16 18:05:10 -04:00
Vitor Pamplona
52fcfebe38 Fixes references in F-droid 2023-08-16 18:03:56 -04:00
Vitor Pamplona
0e1a856555 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-16 17:58:40 -04:00
Vitor Pamplona
255b48d126 - Creates a Quartz Module for Nostr Events
- Creates a Benchmark Module to test Performance
- Migrates from GSon to Jackson for Performance Gains, adapts all serializers accordingly
- Recreates Hex encoding/decoding classes for Performance.
- Migrates NIP24 to the new ByteArray Concat encoding.
- Removes support for Lenient choices in the. events.
- Reorganizes Nostr Events dependencies.
- Refactors TLV's and NIP-19 dependencies.
- Adds a Large DB for signature checks.
2023-08-16 17:58:25 -04: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
Vitor Pamplona
99adbf6691 Merge pull request #549 from ShinoharaTa/fix_broken_url
fix url regex pattern.
2023-08-16 10:39:32 -04:00
Vitor Pamplona
a96760d1a4 Merge pull request #551 from davotoula/update_translations
Added new translations for cs/de/se
2023-08-16 09:47:25 -04:00
David Kaspar
dd9e51e31c Added new translations for cs/de/se 2023-08-16 13:57:56 +01:00
tshinohara
a5b607b30b fix url regax pattern. 2023-08-16 02:48:50 +09:00
Vitor Pamplona
f7e9898637 Testing image uploads with Account info 2023-08-15 11:57:28 -04:00
Vitor Pamplona
1ad1d233cd Refactoring TLV's code 2023-08-15 11:13:01 -04:00
Vitor Pamplona
52687a48df Fixes image uploading tests without account. 2023-08-15 11:10:17 -04:00
Vitor Pamplona
dca4f827fe Moves navigation's top bar to use the ArrowBack 2023-08-14 23:07:08 -04:00
Vitor Pamplona
2726011126 Trim display names when possible 2023-08-14 23:06:33 -04:00
Vitor Pamplona
77bd646b1d Don't display username if display name is available. 2023-08-14 23:06:18 -04:00
Vitor Pamplona
d664d9ff19 Moves Relay List to a surface 2023-08-14 23:05:50 -04:00
Vitor Pamplona
5d24950d7f Starts NIP-65 implementation 2023-08-14 23:05:16 -04:00
Vitor Pamplona
3a8a50006d Improvements to the Settings Interface 2023-08-14 21:25:12 -04:00
Vitor Pamplona
decd6ef627 BugFix for HighlightEvent.create event. 2023-08-14 16:38:54 -04:00
Vitor Pamplona
6ab061a5a2 Avoids displaying a NIP-94 event without a url tag 2023-08-14 16:33:47 -04:00
Vitor Pamplona
d934e28b3a Updates some dependencies while we wait for Android 34's Gradle plugin to be released. 2023-08-14 16:10:43 -04:00
Vitor Pamplona
57588fd6ac no need for these idea files in GitHub 2023-08-14 16:05:01 -04:00
greenart7c3
66b1e4b15d add signer dialog to communities and public chats 2023-08-14 16:34:40 -03:00
Vitor Pamplona
921d52cb8c Moderated communities was renamed. 2023-08-14 15:12:18 -04:00
Vitor Pamplona
4a2d673cce Updates Readme for the new Private DMs 2023-08-14 15:02:57 -04: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
Vitor Pamplona
c7a3a74d5a v0.73.3 2023-08-13 17:52:13 -04:00
Vitor Pamplona
eed2f6abaf Unused null section 2023-08-13 17:51:55 -04:00
Vitor Pamplona
80f0b14c02 BugFix for blank screen when pressing the message button without any history of chats 2023-08-13 17:38:44 -04:00
Vitor Pamplona
b12b81282c BugFix for the route in the DM Notification 2023-08-13 17:24:30 -04:00
Vitor Pamplona
07d661ea00 Only updates the Bookmarks and Follows flag when the sensitive content property of the account changes. 2023-08-12 21:40:09 -04:00
Vitor Pamplona
a15263eeb9 Moves zaps from Compose scope to ViewModel Scope 2023-08-12 21:38:14 -04:00
Vitor Pamplona
6f19bccc54 Observes the user's follow list and not the local account state to update TagLists on the screen 2023-08-12 21:37:31 -04:00
Vitor Pamplona
547d567431 Avoids account state redirections 2023-08-12 21:36:31 -04:00
Vitor Pamplona
ece4e9074a Updates the isHidden function to the same version the NoteCompose uses. 2023-08-12 21:36:13 -04:00
Vitor Pamplona
0737c6ddc3 Avoids a redirection of account State 2023-08-12 21:35:50 -04:00
Vitor Pamplona
2aa9458aa1 Moves Show And Hide user functions from ComposeScoped to ViewModel Scoped 2023-08-12 21:35:34 -04:00
Vitor Pamplona
cb3b898e29 Moves the creation of the TopBar livedata for lists into the AccountViewModel 2023-08-12 21:33:29 -04:00
Vitor Pamplona
ceac61eb19 Avoiding live data updates 2023-08-12 21:32:15 -04:00
Vitor Pamplona
bc9f5e5328 Moves HTTP regex compilation to outside the inner method 2023-08-12 21:31:49 -04:00
Vitor Pamplona
a293800897 Moves Regex compilation to outside the state class. 2023-08-12 21:31:10 -04:00
Vitor Pamplona
3be34d0d40 Speeding up regex 2023-08-12 18:15:45 -04:00
Vitor Pamplona
eec87f017a v0.73.2 2023-08-12 15:18:26 -04:00
Vitor Pamplona
799037502f Correctly displaying/hiding pictures in Chat Compose 2023-08-12 15:17:05 -04:00
Vitor Pamplona
f2b727c587 Fixes the PushNotification of GiftWrapped Messages 2023-08-12 15:00:30 -04:00
Vitor Pamplona
b255d1827e Forces the creation of Notification Channels to see if they show up on Samsung phones. 2023-08-12 14:59:53 -04:00
Vitor Pamplona
d9e01da7e2 Removes unnecessary code 2023-08-12 14:59:14 -04:00
Vitor Pamplona
99a98439cd Uses the right account to decrypt GiftWraps 2023-08-12 14:56:29 -04:00
Vitor Pamplona
724e7e2378 Merge pull request #547 from KotlinGeekDev/fix-landscape-issue
Fix menu drawer in landscape mode issue
2023-08-12 14:15:43 -04:00
Vitor Pamplona
0619c9ffac Displays GiftWraps as Messages in the Notification Screen 2023-08-12 14:12:46 -04:00
Vitor Pamplona
cb7b51e8d1 BugFix for marking chat as read 2023-08-12 13:50:40 -04:00
Vitor Pamplona
c8172265dd v0.73.1 2023-08-12 11:20:13 -04:00
Vitor Pamplona
cce9d424bc Fixes green color for incognito mode 2023-08-12 11:20:01 -04:00
Vitor Pamplona
45227b75dc Fixed ProGuard for libsodium 2023-08-12 11:15:20 -04:00
Vitor Pamplona
6eaba7956c Sets releases as pre-release by default. 2023-08-12 10:31:08 -04:00
Vitor Pamplona
cc4b94f738 v0.73.0 2023-08-12 10:10:09 -04:00
Vitor Pamplona
423628a104 Adding ways to update the subject 2023-08-12 10:04:15 -04:00
Vitor Pamplona
d262b48f31 BugFix: Chat List updating immediately when new messages arrive. 2023-08-11 20:04:37 -04:00
Vitor Pamplona
f97a0468cc BugFix for when the private room is with yourself. 2023-08-11 19:47:17 -04:00
Vitor Pamplona
b4d87686ac Adds an incognito badge in every msg 2023-08-11 19:46:59 -04:00
Vitor Pamplona
8cbf07c917 Moves GiftCard filter to Account filters and not Chats 2023-08-11 19:46:26 -04:00
Vitor Pamplona
30fc4c35f4 Only requests notification once. 2023-08-11 19:44:34 -04:00
Vitor Pamplona
7912d492e5 Makes sure replies of NIP24 are also NIP24 2023-08-11 19:43:36 -04:00
KotlinGeekDev
d921eb6138 Preserve behaviour pre-fix(i.e if a user opened the drawer and landscaped by choice). 2023-08-11 23:32:27 +01:00
KotlinGeekDev
6875fdc4d0 Watch orientation change, and close drawer in landscape mode. 2023-08-11 23:27:04 +01:00
Vitor Pamplona
0bc701f5c9 Adding a way to visualize all members of a group 2023-08-10 21:27:30 -04:00
Vitor Pamplona
5de0808b1e Merge pull request #545 from jeremyd/fix_global_relay_selection
fixing global relays selection
2023-08-10 20:06:59 -04:00
Vitor Pamplona
9d680c9f82 Merge pull request #546 from Bardesss/main-1
Update strings.xml (NL)
2023-08-10 20:06:06 -04:00
Vitor Pamplona
63fc4c570d Fixed Alby NWC link 2023-08-10 18:04:38 -04:00
Vitor Pamplona
ab2fff0194 Support for NIP24- Trustless GiftWrapped Sealed Private Direct Messages and Small Private Groups 2023-08-10 18:04:23 -04:00
Bardesss
c644ba9a3e Update strings.xml (NL)
Added missing strings.
2023-08-10 09:17:32 +00:00
jeremyd
2791873048 fixing global relays selection 2023-08-08 19:42:19 -07:00
Vitor Pamplona
89266bc76f Merge pull request #542 from frnandu/main
fix signed byte used for array slice with + added test
2023-08-07 09:14:27 -04:00
Fernando Martins
9573b4abec fix signed byte used for array slice with + added test 2023-08-07 11:56:11 +02:00
Vitor Pamplona
ddcff8fa15 don't return reposts and reactions in Search 2023-08-05 10:44:20 -04:00
Vitor Pamplona
d36f6f4a7e v0.72.2 2023-08-04 21:06:14 -04:00
Vitor Pamplona
c2b72f1e45 TimeAgo refactoring 2023-08-04 21:04:13 -04:00
Vitor Pamplona
28e6471ade Puts marked as read on chat to an IO thread 2023-08-04 21:03:19 -04:00
Vitor Pamplona
c3b8eb5087 Puts an invalidate data on an IO thread 2023-08-04 21:02:45 -04:00
Vitor Pamplona
75779e7ddc simple refactoring 2023-08-04 21:00:07 -04:00
Vitor Pamplona
118bc7b73c Don't invalidate data on channels if it is already invalidating 2023-08-04 20:58:10 -04:00
Vitor Pamplona
e6a0fdedd9 Removes unnecessary signing step 2023-08-04 20:57:21 -04:00
Vitor Pamplona
28098fafd7 Don't push to invalidate unless there is an observer. 2023-08-04 20:56:22 -04:00
Vitor Pamplona
0c0e87af21 Moves headers to the top nav bar 2023-08-04 16:50:23 -04:00
Vitor Pamplona
8abfd7149b Removes the need for .0 when on base sats. 2023-08-04 15:24:41 -04:00
Vitor Pamplona
674896cea4 Moves coroutine creation to the viewModel 2023-08-04 15:20:31 -04:00
Vitor Pamplona
ec514651fc Makes the New Channel creation scrollable. 2023-08-04 15:18:30 -04:00
407 changed files with 35771 additions and 8715 deletions

View File

@@ -76,7 +76,7 @@ jobs:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
prerelease: true
# Google Play APK
- name: Upload Play APK Universal Asset
@@ -86,7 +86,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-signed.apk
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -97,7 +97,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-signed.apk
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -108,10 +108,32 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86_64-release-signed.apk
asset_path: app/build/outputs/apk/play/release/app-play-x86_64-release-unsigned-signed.apk
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
@@ -120,7 +142,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-universal-release-signed.apk
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -131,7 +153,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86-release-signed.apk
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -142,10 +164,32 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-signed.apk
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-unsigned-signed.apk
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 }}

3
.idea/.gitignore generated vendored
View File

@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated
View File

@@ -1 +0,0 @@
Amethyst

View File

@@ -3,38 +3,30 @@
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

View File

@@ -75,17 +75,21 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [x] Moderated Communities (NIP-172)
- [x] Moderated Communities (NIP-72)
- [x] Emoji Packs (Kind:30030)
- [x] Personal Emoji Lists (Kind:10030)
- [x] Classifieds (Kind:30403)
- [x] Private Messages and Small Groups (NIP-24)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Versioned Encrypted Payloads (NIP-44)
- [x] Expiration Support (NIP-40)
- [x] Status Event (NIP-38)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Workspaces
- [ ] Expiration Support (NIP-40)
- [ ] Infinity Scroll
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
@@ -102,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
@@ -168,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>`
@@ -188,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

@@ -1,31 +1,31 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jlleitschuh.gradle.ktlint' version "11.5.0"
id 'org.jlleitschuh.gradle.ktlint' version "11.5.1"
id 'com.google.gms.google-services'
}
android {
namespace 'com.vitorpamplona.amethyst'
compileSdk 33
compileSdk 34
defaultConfig {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 268
versionName "0.72.1"
targetSdk 34
versionCode 297
versionName "0.77.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW', 'ja']
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 {
release {
signingConfig signingConfigs.debug
//signingConfig signingConfigs.debug
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled true
resValue "string", "app_name", "@string/app_name_release"
@@ -54,7 +54,7 @@ android {
abi {
enable true
reset()
include "x86", "x86_64"
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
universalApk true
}
}
@@ -87,7 +87,8 @@ android {
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation project(path: ':quartz')
implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.7.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -110,22 +111,16 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "net.engawapg.lib:zoomable:1.4.3"
// Zoomable images
implementation 'net.engawapg.lib:zoomable:1.5.1'
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
// HTML Parsing for Link Preview
implementation 'org.jsoup:jsoup:1.16.1'
// Encrypted Key Storage
@@ -149,9 +144,6 @@ dependencies {
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// Parses URLs from Text:
implementation "io.github.url-detector:url-detector:0.1.23"
// For QR generation
implementation 'com.google.zxing:core:3.5.2'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
@@ -168,9 +160,9 @@ dependencies {
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
implementation 'androidx.media3:media3-ui:1.1.0'
implementation 'androidx.media3:media3-session:1.1.0'
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-session:$media3_version"
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
@@ -179,7 +171,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.2.0')
playImplementation platform('com.google.firebase:firebase-bom:32.2.3')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
@@ -188,15 +180,11 @@ dependencies {
implementation "com.patrykandpatrick.vico:views:${vico_version}"
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
// immutable collections to avoid recomposition
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
// GeoHash
implementation 'com.github.drfonfon:android-kotlin-geohash:1.0'
// LibSodium for XChaCha encryption
implementation "com.goterl:lazysodium-android:5.1.0@aar"
implementation 'net.java.dev.jna:jna:5.13.0@aar'
// Waveform visualizer
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
@@ -204,7 +192,7 @@ dependencies {
implementation 'id.zelory:compressor:3.0.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
testImplementation 'io.mockk:mockk:1.13.7'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"

View File

@@ -30,23 +30,31 @@
# preserve access to native classses
-keep class fr.acinq.secp256k1.** { *; }
# GSON parsing
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
# JNA For Libsodium
-keep class com.goterl.lazysodium.** { *; }
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Prevent R8 from leaving Data object members always null
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
-keep class com.sun.jna.ToNativeConverter { *; }
-keep class com.sun.jna.NativeMapped { *; }
-keep class com.sun.jna.CallbackReference { *; }
-keep class com.sun.jna.ptr.IntByReference { *; }
-keep class com.sun.jna.NativeLong { *; }
-keep class com.sun.jna.Structure { *; }
-keep class com.sun.jna.Structure$* { *; }
-keep class com.sun.jna.Native$ffi_callback { *; }
-keep class * implements com.sun.jna.Structure$* { *; }
-keep class * implements com.sun.jna.Native$* { *; }
-keep class com.sun.jna.Native {
private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object);
private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object);
private static java.lang.Class nativeType(java.lang.Class);
private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object);
private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method);
}
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
# JSON parsing
-keep class com.vitorpamplona.quartz.crypto.** { *; }
-keep class com.vitorpamplona.quartz.encoders.** { *; }
-keep class com.vitorpamplona.quartz.events.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }

View File

@@ -1,113 +1,78 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.FileServer
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import kotlinx.coroutines.delay
import com.vitorpamplona.quartz.crypto.KeyPair
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
import java.util.concurrent.CountDownLatch
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
@Test()
fun testImgurUpload() = runBlocking {
private fun testBase(server: FileServer) {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
val countDownLatch = CountDownLatch(1)
var url: String? = null
var error: String? = null
ImageUploader.account = Account(
KeyPair()
)
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
ImgurServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
server,
onSuccess = { newUrl, contentType ->
println("Uploaded $contentType to $url")
url = newUrl
countDownLatch.countDown()
},
onError = {
println("Failed to Upload")
fail("${it.message}")
error = it.message
countDownLatch.countDown()
}
)
delay(5000)
countDownLatch.await()
Assert.assertNull(error)
Assert.assertTrue(url?.startsWith("http") == true)
}
@Test()
fun testImgurUpload() = runBlocking {
testBase(ImgurServer())
}
@Test()
fun testNostrBuildUpload() = runBlocking {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrBuildServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(1000)
testBase(NostrBuildServer())
}
@Test()
fun testNostrImgUpload() = runBlocking {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrImgServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(1000)
testBase(NostrImgServer())
}
@Test()
fun testNostrFilesDevUpload() = runBlocking {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrFilesDevServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(5000)
testBase(NostrFilesDevServer())
}
}

View File

@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.RichTextParser
import com.vitorpamplona.amethyst.service.RichTextViewerState
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@@ -4128,7 +4128,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
}
private fun printStateForDebug(state: RichTextViewerState) {
state.paragraphs.forEachIndexed { index, paragraph ->
state.paragraphs.forEach { paragraph ->
paragraph.words.forEach { seg ->
println(
"\"${

File diff suppressed because one or more lines are too long

View File

@@ -5,10 +5,10 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.UserMetadata
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -29,7 +29,10 @@ class UrlUserTagTransformationTest {
@Test
fun transformationText() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
val user = LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey()
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
@@ -63,7 +66,10 @@ class UrlUserTagTransformationTest {
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
val user = LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey()
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"

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

@@ -4,8 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@Composable
fun TranslatableRichTextViewer(

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 -->
@@ -60,7 +62,7 @@
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
android:localeConfig="@xml/locales_config"
tools:targetApi="33">
tools:targetApi="34">
<activity
android:name=".ui.MainActivity"
android:exported="true"

View File

@@ -4,8 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
@@ -14,17 +13,16 @@ import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.KeyPair
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.toNpub
import fr.acinq.secp256k1.Hex
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.LnZapEvent
import java.io.File
import java.util.Locale
@@ -37,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 {
@@ -64,6 +63,7 @@ private object PrefKeys {
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
@@ -75,17 +75,16 @@ 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" }
}
private val gson = GsonBuilder().create()
object LocalPreferences {
private const val comma = ","
private var _currentAccount: String? = null
private fun currentAccount(): String? {
fun currentAccount(): String? {
if (_currentAccount == null) {
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
@@ -204,10 +203,18 @@ object LocalPreferences {
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts().map { npub ->
AccountInfo(npub = npub)
AccountInfo(
npub,
hasPrivKey(npub),
getLoggedInWithExternalSigner(npub)
)
}
}
fun allLocalAccountNPubs(): Set<String> {
return savedAccounts().toSet()
}
fun saveToEncryptedStorage(account: Account) {
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs.edit().apply {
@@ -216,33 +223,35 @@ object LocalPreferences {
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(PrefKeys.LANGUAGE_PREFS, Event.mapper.writeValueAsString(account.languagePreferences))
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.REACTION_CHOICES, gson.toJson(account.reactionChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, Event.mapper.writeValueAsString(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.mapper.writeValueAsString(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
putString(PrefKeys.LAST_READ_PER_ROUTE, gson.toJson(account.lastReadPerRoute))
putString(PrefKeys.LAST_READ_PER_ROUTE, Event.mapper.writeValueAsString(account.lastReadPerRoute))
if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.loginWithExternalSigner)
}.apply()
val globalPrefs = encryptedPreferences()
@@ -274,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? {
@@ -300,10 +311,10 @@ object LocalPreferences {
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString(PrefKeys.RELAYS, "[]"),
object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<RelaySetupInfo>()
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
println("LocalRelays: $it")
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
} ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
@@ -312,29 +323,25 @@ object LocalPreferences {
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let {
Event.mapper.readValue<List<Long>?>(it)
}?.ifEmpty { listOf(500L, 1000L, 5000L) } ?: listOf(500L, 1000L, 5000L)
val reactionChoices = gson.fromJson<List<String>>(
getString(PrefKeys.REACTION_CHOICES, "[]"),
object : TypeToken<List<String>>() {}.type
).ifEmpty { listOf("+") } ?: listOf("+")
val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let {
Event.mapper.readValue<List<String>?>(it)
}?.ifEmpty { listOf("+") } ?: listOf("+")
val defaultZapType = gson.fromJson(
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
object : TypeToken<LnZapEvent.ZapType>() {}.type
) ?: LnZapEvent.ZapType.PUBLIC
val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = gson.fromJson(
getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"),
object : TypeToken<ServersAvailable>() {}.type
) ?: ServersAvailable.NOSTR_BUILD
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
ServersAvailable.values().firstOrNull() { it.name == serverName }
} ?: ServersAvailable.NOSTR_BUILD
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Nip47URI::class.java)
Event.mapper.readValue<Nip47URI?>(it)
}
} catch (e: Throwable) {
e.printStackTrace()
@@ -343,8 +350,7 @@ object LocalPreferences {
val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
Event.gson.fromJson(it, Event::class.java)
.getRefinedEvent(true) as ContactListEvent
Event.fromJson(it) as ContactListEvent?
}
} catch (e: Throwable) {
e.printStackTrace()
@@ -353,10 +359,7 @@ object LocalPreferences {
val languagePreferences = try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
gson.fromJson(
it,
object : TypeToken<Map<String, String>>() {}.type
) as Map<String, String>
Event.mapper.readValue<Map<String, String>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
@@ -365,9 +368,11 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
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)
@@ -379,10 +384,7 @@ object LocalPreferences {
val lastReadPerRoute = try {
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
gson.fromJson(
it,
object : TypeToken<Map<String, Long>>() {}.type
) as Map<String, Long>
Event.mapper.readValue<Map<String, Long>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
@@ -431,6 +433,7 @@ object LocalPreferences {
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP24WarningDialog = hideNIP24WarningDialog,
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,
@@ -438,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

@@ -26,15 +26,35 @@ class PlaybackService : MediaSessionService() {
return ProgressiveMediaSource.Factory(VideoCache.get(Amethyst.instance, HttpClient.getHttpClient()))
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
managerHls?.let { return it }
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerHls = newInstance
return newInstance
}
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
managerProgressive?.let { return it }
val newInstance = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerProgressive = newInstance
return newInstance
}
fun lazyLocalDS(): MultiPlayerPlaybackManager {
managerLocal?.let { return it }
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
managerLocal = newInstance
return newInstance
}
// Create your Player and MediaSession in the onCreate lifecycle event
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
@@ -62,11 +82,11 @@ class PlaybackService : MediaSessionService() {
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
return if (fileName.startsWith("file")) {
managerLocal
lazyLocalDS()
} else if (fileName.endsWith("m3u8")) {
managerHls
lazyHlsDS()
} else {
managerProgressive
lazyProgressiveDS()
}
}

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
@@ -31,18 +32,25 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
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
@@ -87,6 +95,7 @@ object ServiceManager {
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
isStarted = true
}
}
@@ -112,16 +121,25 @@ 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()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
LocalCache.pruneRepliesAndReactions(it)
LocalCache.pruneContactLists(accounts)
LocalCache.pruneRepliesAndReactions(accounts)
LocalCache.prunePastVersionsOfReplaceables()
LocalCache.pruneExpiredEvents()
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,11 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.Dispatchers
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)

View File

@@ -4,13 +4,14 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.toNote
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
@@ -163,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

@@ -1,52 +0,0 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.KeyPair
import com.vitorpamplona.amethyst.service.bechToBytes
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
/** Makes the distinction between String and Hex **/
typealias HexKey = String
fun ByteArray.toHexKey(): HexKey {
return Hex.encode(this)
}
fun HexKey.hexToByteArray(): ByteArray {
return Hex.decode(this)
}
fun HexKey.toDisplayHexKey(): String {
return this.toShortenHex()
}
fun decodePublicKey(key: String): ByteArray {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.hexToByteArray()
return if (key.startsWith("nsec")) {
KeyPair(privKey = key.bechToBytes()).pubKey
} else if (pubKeyParsed != null) {
pubKeyParsed
} else {
Hex.decode(key)
}
}
fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
return try {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex
if (key.startsWith("nsec")) {
KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey()
} else if (pubKeyParsed != null) {
pubKeyParsed
} else {
Hex.decode(key).toHexKey()
}
} catch (e: Exception) {
null
}
}

View File

@@ -3,13 +3,24 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HexValidator
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
import com.vitorpamplona.quartz.encoders.ATag
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -45,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
}
@@ -90,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
}
@@ -190,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()}")
@@ -198,26 +225,6 @@ object LocalCache {
}
}
fun consume(event: PeopleListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
@@ -323,25 +330,6 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: LiveActivitiesEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -366,102 +354,16 @@ object LocalCache {
}
}
fun consume(event: EmojiPackSelectionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: EmojiPackEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: ClassifiedsEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: PinListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: RelaySetEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: AudioTrackEvent) {
fun consume(event: PeopleListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: AdvertisedRelayListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event) }
fun consume(event: EmojiPackSelectionEvent) { consumeBaseReplaceable(event) }
private fun consume(event: EmojiPackEvent) { consumeBaseReplaceable(event) }
private fun consume(event: ClassifiedsEvent) { consumeBaseReplaceable(event) }
private fun consume(event: PinListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: RelaySetEvent) { consumeBaseReplaceable(event) }
private fun consume(event: AudioTrackEvent) { consumeBaseReplaceable(event) }
fun consume(event: StatusEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -477,30 +379,14 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: BadgeDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<Note>())
author.liveSet?.innerStatuses?.invalidateData()
refreshObservers(note)
}
}
fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) }
fun consume(event: BadgeProfilesEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -515,7 +401,7 @@ object LocalCache {
if (note.event?.id() == event.id()) return
val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, replyTo)
@@ -545,7 +431,14 @@ object LocalCache {
refreshObservers(note)
}
private fun comsume(event: NNSEvent) {
private fun comsume(event: NNSEvent) { consumeBaseReplaceable(event) }
fun consume(event: AppDefinitionEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarDateSlotEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarTimeSlotEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarRSVPEvent) { consumeBaseReplaceable(event) }
private fun consumeBaseReplaceable(event: BaseAddressableEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -565,45 +458,7 @@ object LocalCache {
}
}
fun consume(event: AppDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: AppRecommendationEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: AppRecommendationEvent) { consumeBaseReplaceable(event) }
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
@@ -703,7 +558,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -725,7 +580,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -814,7 +669,7 @@ object LocalCache {
mentions.forEach {
// doesn't add to reports, but triggers recounts
it.liveSet?.reports?.invalidateData()
it.liveSet?.innerReports?.invalidateData()
}
}
@@ -971,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 {
@@ -1025,6 +880,23 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: AudioHeaderEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: FileHeaderEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1122,7 +994,29 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet()
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
if (recipients.isNotEmpty()) {
recipients.forEach {
val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex)
val authorGroup = if (groupMinusRecipient.isEmpty()) {
// note to self
ChatroomKey(persistentSetOf(it.pubkeyHex))
} else {
ChatroomKey(groupMinusRecipient.toImmutableSet())
}
it.addMessage(authorGroup, note)
}
}
refreshObservers(note)
}
@@ -1144,7 +1038,7 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: GiftWrapEvent, relay: Relay?) {
fun consume(event: GiftWrapEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1236,14 +1130,20 @@ object LocalCache {
}
return notes.values.filter {
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true) ||
it.idNote().startsWith(text, true)
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) &&
(
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true) ||
it.idNote().startsWith(text, true)
)
} + addressables.values.filter {
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true)
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) &&
(
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true)
)
}
}
@@ -1267,6 +1167,18 @@ object LocalCache {
}
}
suspend fun findStatusesForUser(user: User): ImmutableList<AddressableNote> {
checkNotInMainThread()
return addressables.filter {
val noteEvent = it.value.event
(noteEvent is StatusEvent && noteEvent.pubKey == user.pubkeyHex && !noteEvent.isExpired() && noteEvent.content.isNotBlank())
}.values
.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex }))
.reversed()
.toImmutableList()
}
fun cleanObservers() {
notes.forEach {
it.value.clearLive()
@@ -1290,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 { _ ->
it.removeReply(it)
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1315,12 +1221,7 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1334,9 +1235,37 @@ object LocalCache {
}
}
fun pruneRepliesAndReactions(account: Account) {
fun prunePastVersionsOfReplaceables() {
val toBeRemoved = notes.filter {
val noteEvent = it.value.event
if (noteEvent is AddressableEvent) {
noteEvent.createdAt() < (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0)
} else {
false
}
}.values
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
val newerVersion = addressables[(it.event as? AddressableEvent)?.address()?.toTag()]
if (newerVersion != null) {
it.moveAllReferencesTo(newerVersion)
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
}
}
fun pruneRepliesAndReactions(accounts: Set<HexKey>) {
checkNotInMainThread()
val user = account.userProfile()
val toBeRemoved = notes.filter {
(
@@ -1344,27 +1273,113 @@ 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 != user && // don't delete if it is the logged in account
it.value.event?.isTaggedUser(user.pubkeyHex) != true // don't delete if it's a notification to the logged in user
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
}.values
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.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 toBeRemoved = notes.filter {
it.value.event?.isExpired() == true
}.values
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
@@ -1386,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())
}
@@ -1405,27 +1410,15 @@ 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(userAccount: Account) {
fun pruneContactLists(loggedIn: Set<HexKey>) {
checkNotInMainThread()
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
if (it.pubkeyHex !in loggedIn &&
(it.liveSet == null || it.liveSet?.isInUse() == false) &&
it.latestContactList != null
) {
it.latestContactList = null
removingContactList++
}
@@ -1442,26 +1435,44 @@ object LocalCache {
}
fun verifyAndConsume(event: Event, relay: Relay?) {
if (justVerify(event)) {
justConsume(event, relay)
}
}
fun justVerify(event: Event): Boolean {
checkNotInMainThread()
if (!event.hasValidSignature()) {
return if (!event.hasValidSignature()) {
try {
event.checkSignature()
} catch (e: Exception) {
Log.w("Event failed retest ${event.kind}", e.message ?: "")
}
return
false
} else {
true
}
}
fun justConsume(event: Event, relay: Relay?) {
checkNotInMainThread()
try {
when (event) {
is AdvertisedRelayListEvent -> consume(event)
is AppDefinitionEvent -> consume(event)
is AppRecommendationEvent -> consume(event)
is AudioHeaderEvent -> consume(event, relay)
is AudioTrackEvent -> consume(event)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
is BadgeProfilesEvent -> consume(event)
is BookmarkListEvent -> consume(event)
is CalendarEvent -> consume(event)
is CalendarDateSlotEvent -> consume(event)
is CalendarTimeSlotEvent -> consume(event)
is CalendarRSVPEvent -> consume(event)
is ChannelCreateEvent -> consume(event)
is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay)
@@ -1491,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)
@@ -1504,6 +1516,7 @@ object LocalCache {
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event)
is PeopleListEvent -> consume(event)
is PollNoteEvent -> consume(event, relay)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is RelaySetEvent -> consume(event)
@@ -1520,8 +1533,9 @@ object LocalCache {
}
consume(event)
}
is StatusEvent -> consume(event, relay)
is TextNoteEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}

View File

@@ -1,3 +1,5 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)

View File

@@ -3,28 +3,31 @@ 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 com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.toNote
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
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
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.regex.Pattern
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
@@ -73,7 +76,22 @@ open class Note(val idHex: String) {
open fun idNote() = id().toNote()
open fun toNEvent(): String {
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
val myEvent = event
return if (myEvent is WrappedEvent) {
val host = myEvent.host
if (host != null) {
Nip19.createNEvent(
host.id,
host.pubKey,
host.kind(),
relays.firstOrNull()
)
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
}
fun toNostrUri(): String {
@@ -104,11 +122,13 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
this.event = event
this.author = author
this.replyTo = replyTo
if (this.event?.id() != event.id()) {
this.event = event
this.author = author
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
}
fun formattedDateTime(timestamp: Long): String {
@@ -116,20 +136,63 @@ 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(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
fun replyLevelSignature(
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
)
}
return replyTo
.map {
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
val parent = (
replyTo
.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,
account,
accountFollowingSet,
now
).apply { cachedSignatures.put(it, this) }
}
.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
}
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
@@ -146,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()
}
}
@@ -183,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
}
@@ -206,7 +269,7 @@ open class Note(val idHex: String) {
reactions = reactions + Pair(reaction, newList)
}
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
}
}
}
@@ -218,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()
}
}
}
@@ -226,29 +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)) {
val toRemove = zaps.filterValues { it == note }
zaps = zaps.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
zaps = zaps.filterValues { it != note }
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()
}
}
@@ -270,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()
}
}
}
@@ -298,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()
}
}
}
@@ -314,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()
}
}
@@ -326,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()
}
}
@@ -436,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()
@@ -605,6 +663,7 @@ open class Note(val idHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
@@ -626,16 +685,60 @@ 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 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 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 = innerMetadata.map {
it.note.event != null
}.distinctUntilChanged()
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 = innerReplies.map {
it.note.replies.size
}.distinctUntilChanged()
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() ||
@@ -644,26 +747,56 @@ 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() {
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)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
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) {
@@ -685,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

@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey
class ParticipantListBuilder {
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
baseNote.author?.let { author ->

View File

@@ -1,15 +1,12 @@
package com.vitorpamplona.amethyst.model
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import java.lang.reflect.Type
import androidx.compose.runtime.Stable
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@Stable
class RelayInformation(
val id: String?,
val name: String?,
val description: String?,
val pubkey: String?,
@@ -27,113 +24,26 @@ class RelayInformation(
val fees: RelayInformationFees?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformation::class.java, RelayInformationSerializer())
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
.create()
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun fromJson(json: String): RelayInformation = gson.fromJson(json, RelayInformation::class.java)
fun fromJson(json: String): RelayInformation = mapper.readValue(json, RelayInformation::class.java)
}
}
@Stable
class RelayInformationFee(
val amount: Int?,
val unit: String?,
val period: Int?,
val kinds: List<Int>?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
.create()
fun fromJson(json: String): RelayInformationFee = gson.fromJson(json, RelayInformationFee::class.java)
}
}
private class RelayInformationFeeSerializer : JsonSerializer<RelayInformationFee> {
override fun serialize(
src: RelayInformationFee,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("amount", src.amount)
addProperty("unit", src.unit)
addProperty("period", src.period)
add(
"kinds",
JsonArray().also { kinds ->
src.kinds?.forEach { kind ->
kinds.add(
kind
)
}
}
)
}
}
}
)
class RelayInformationFees(
val admission: List<RelayInformationFee>?,
val subscription: List<RelayInformationFee>?,
val publication: List<RelayInformationFee>?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
.create()
fun fromJson(json: String): RelayInformationFees = gson.fromJson(json, RelayInformationFees::class.java)
}
}
private class RelayInformationFeesSerializer : JsonSerializer<RelayInformationFees> {
override fun serialize(
src: RelayInformationFees,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
add(
"admission",
JsonArray().also { admissions ->
src.admission?.forEach { admission ->
admissions.add(
admission.toString()
)
}
}
)
add(
"publication",
JsonArray().also { publications ->
src.publication?.forEach { publication ->
publications.add(
publication.toString()
)
}
}
)
add(
"subscription",
JsonArray().also { subscriptions ->
src.subscription?.forEach { subscription ->
subscriptions.add(
subscription.toString()
)
}
}
)
}
}
}
val publication: List<RelayInformationFee>?,
val retention: List<RelayInformationFee>?
)
class RelayInformationLimitation(
val max_message_length: Int?,
@@ -147,104 +57,4 @@ class RelayInformationLimitation(
val min_pow_difficulty: Int?,
val auth_required: Boolean?,
val payment_required: Boolean?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
.create()
fun fromJson(json: String): RelayInformationLimitation = gson.fromJson(json, RelayInformationLimitation::class.java)
}
}
private class RelayInformationLimitationSerializer : JsonSerializer<RelayInformationLimitation> {
override fun serialize(
src: RelayInformationLimitation,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("max_message_length", src.max_message_length)
addProperty("max_subscriptions", src.max_subscriptions)
addProperty("max_filters", src.max_filters)
addProperty("max_limit", src.max_limit)
addProperty("max_subid_length", src.max_subid_length)
addProperty("min_prefix", src.min_prefix)
addProperty("max_event_tags", src.max_event_tags)
addProperty("max_content_length", src.max_content_length)
addProperty("min_pow_difficulty", src.min_pow_difficulty)
addProperty("auth_required", src.auth_required)
addProperty("payment_required", src.payment_required)
}
}
}
private class RelayInformationSerializer : JsonSerializer<RelayInformation> {
override fun serialize(
src: RelayInformation,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("name", src.name)
addProperty("description", src.description)
addProperty("pubkey", src.pubkey)
addProperty("contact", src.contact)
add(
"supported_nip_extensions",
JsonArray().also { supported_nip_extensions ->
src.supported_nip_extensions?.forEach { nip ->
supported_nip_extensions.add(
nip
)
}
}
)
add(
"supported_nips",
JsonArray().also { supported_nips ->
src.supported_nips?.forEach { nip ->
supported_nips.add(
nip
)
}
}
)
addProperty("software", src.software)
addProperty("version", src.version)
add(
"relay_countries",
JsonArray().also { relay_countries ->
src.relay_countries?.forEach { country ->
relay_countries.add(
country
)
}
}
)
add(
"language_tags",
JsonArray().also { language_tags ->
src.language_tags?.forEach { language_tag ->
language_tags.add(
language_tag
)
}
}
)
add(
"tags",
JsonArray().also { tags ->
src.tags?.forEach { tag ->
tags.add(
tag
)
}
}
)
addProperty("posting_policy", src.posting_policy)
addProperty("payments_url", src.payments_url)
}
}
}
)

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

@@ -1,9 +1,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import kotlin.time.ExperimentalTime
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue
class ThreadAssembler {
@@ -16,9 +15,14 @@ class ThreadAssembler {
testedNotes.add(note)
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
if (markedAsRoot != null) {
// Check to ssee if there is an error in the tag and the root has replies
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
return LocalCache.checkGetOrCreateNote(markedAsRoot)
}
}
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
// recursive
@@ -37,7 +41,6 @@ class ThreadAssembler {
return null
}
@OptIn(ExperimentalTime::class)
fun findThreadFor(noteId: String): Set<Note> {
checkNotInMainThread()

View File

@@ -3,27 +3,29 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Bech32
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.toNpub
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
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
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.LnZapEvent
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 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) {
@@ -43,7 +45,7 @@ class User(val pubkeyHex: String) {
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
var privateChatrooms = mapOf<User, Chatroom>()
var privateChatrooms = mapOf<ChatroomKey, Chatroom>()
private set
fun pubkey() = Hex.decode(pubkeyHex)
@@ -55,6 +57,21 @@ class User(val pubkeyHex: String) {
override fun toString(): String = pubkeyHex
fun toBestShortFirstName(): String {
val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex()
val names = fullName.split(' ')
val firstName = if (names[0].length <= 3) {
// too short. Remove Dr.
"${names[0]} ${names.getOrNull(1) ?: ""}"
} else {
names[0]
}
return firstName
}
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
}
@@ -80,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) {
@@ -90,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) {
@@ -109,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()
}
}
@@ -122,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()
}
}
}
@@ -130,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()
}
}
@@ -174,35 +195,52 @@ class User(val pubkeyHex: String) {
}
@Synchronized
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
checkNotInMainThread()
return privateChatrooms[user] ?: run {
return privateChatrooms[key] ?: run {
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
privateChatroom
}
}
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
return getOrCreatePrivateChatroom(key)
}
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom {
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
}
fun addMessage(room: ChatroomKey, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
}
}
fun addMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
fun createChatroom(withKey: ChatroomKey) {
getOrCreatePrivateChatroom(withKey)
}
fun removeMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -217,7 +255,7 @@ class User(val pubkeyHex: String) {
here.counter++
}
liveSet?.relayInfo?.invalidateData()
liveSet?.innerRelayInfo?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
@@ -226,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 {
@@ -309,8 +339,8 @@ class User(val pubkeyHex: String) {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
}
@@ -336,33 +366,78 @@ class User(val pubkeyHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
@Stable
class UserLiveSet(u: User) {
val innerMetadata = UserBundledRefresherLiveData(u)
// 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 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 = innerMetadata.map {
it.user.nip05()
}.distinctUntilChanged()
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()
bookmarks.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers()
}
fun destroy() {
innerMetadata.destroy()
innerFollows.destroy()
innerFollowers.destroy()
innerReports.destroy()
innerMessages.destroy()
innerRelays.destroy()
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerStatuses.destroy()
}
}
@@ -373,119 +448,35 @@ data class RelayInfo(
var counter: Long
)
class Chatroom() {
var roomMessages: Set<Note> = setOf()
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
}
}
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
}
}
@Stable
class UserMetadata {
var name: String? = null
var username: String? = null
var display_name: String? = null
var displayName: String? = null
var picture: String? = null
var banner: String? = null
var website: String? = null
var about: String? = null
var nip05: String? = null
var nip05Verified: Boolean = false
var nip05LastVerificationTime: Long? = 0
var domain: String? = null
var lud06: String? = null
var lud16: String? = null
var publish: String? = null
var iris: String? = null
var main_relay: String? = null
var twitter: String? = null
var updatedMetadataAt: Long = 0
var latestMetadata: MetadataEvent? = null
var tags: ImmutableListOfLists<String>? = null
fun anyName(): String? {
return display_name ?: displayName ?: name ?: username
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.contains(prefix, true) }
}
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
fun bestUsername(): String? {
return name?.ifBlank { null } ?: username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return displayName?.ifBlank { null } ?: display_name?.ifBlank { null }
}
fun nip05(): String? {
return nip05?.ifBlank { null }
}
fun profilePicture(): String? {
if (picture.isNullOrBlank()) picture = null
return picture
}
}
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)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
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

@@ -6,7 +6,6 @@ import android.util.Patterns
import androidx.compose.runtime.Immutable
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
@@ -15,6 +14,7 @@ import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
@@ -59,6 +59,8 @@ val noProtocolUrlValidator = try {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
}
val HTTPRegex = "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
class RichTextParser() {
fun parseText(
content: String,
@@ -75,8 +77,7 @@ class RichTextParser() {
} else if (it.originalUrl.contains("")) {
null
} else {
val pattern = "^((http|https)://)?([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(it.originalUrl)) {
if (HTTPRegex.matches(it.originalUrl)) {
it.originalUrl
} else {
null
@@ -179,10 +180,10 @@ class RichTextParser() {
BechSegment(word)
} else if (word.startsWith("#")) {
parseHash(word, tags)
} else if (schemelessMatcher.find()) {
} else if (word.contains(".") && schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
val pattern = "^([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
val pattern = """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""".toRegex(RegexOption.IGNORE_CASE)
if (pattern.find(word) != null) {
SchemelessUrlSegment(word, url, additionalChars)
} else {

View File

@@ -1,12 +1,11 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.quartz.events.Event
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -19,7 +18,7 @@ data class CashuToken(
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonArray
val proofs: JsonNode
)
class CashuProcessor {
@@ -28,14 +27,14 @@ class CashuProcessor {
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
val proofs = token["proofs"].asJsonArray
val mint = token["mint"].asString
val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token)))
val token = cashu.get("token").get(0)
val proofs = token.get("proofs")
val mint = token.get("mint").asText()
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.asJsonObject["amount"].asLong
totalAmount += proof.get("amount").asLong()
}
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
val redeemInvoiceAmount = totalAmount - fees
@@ -46,7 +45,7 @@ class CashuProcessor {
}
}
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
runCatching {
@@ -69,9 +68,11 @@ class CashuProcessor {
val client = HttpClient.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val jsonObject = JsonObject()
jsonObject.add("proofs", token.proofs)
jsonObject.addProperty("pr", invoice)
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("proofs", token.proofs)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)

View File

@@ -1,132 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.utils.Key
import fr.acinq.secp256k1.Hex
import fr.acinq.secp256k1.Secp256k1
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object CryptoUtils {
private val secp256k1 = Secp256k1.get()
private val random = SecureRandom()
/**
* Provides a 32B "private key" aka random number
*/
fun privkeyCreate(): ByteArray {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return bytes
}
fun pubkeyCreate(privKey: ByteArray) =
secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)).copyOfRange(1, 33)
fun sign(data: ByteArray, privKey: ByteArray): ByteArray =
secp256k1.signSchnorr(data, privKey, null)
fun verifySignature(
signature: ByteArray,
hash: ByteArray,
pubKey: ByteArray
): Boolean {
return secp256k1.verifySchnorr(signature, hash, pubKey)
}
fun sha256(data: ByteArray): ByteArray {
// Creates a new buffer every time
return MessageDigest.getInstance("SHA-256").digest(data)
}
/**
* @return 32B shared secret
*/
fun getSharedSecretNIP04(privateKey: ByteArray, pubKey: ByteArray): ByteArray =
secp256k1.pubKeyTweakMul(Hex.decode("02") + pubKey, privateKey).copyOfRange(1, 33)
fun encryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
val sharedSecret = getSharedSecretNIP04(privateKey, pubKey)
return encryptNIP04(msg, sharedSecret)
}
fun encryptNIP04(msg: String, sharedSecret: ByteArray): String {
val iv = ByteArray(16)
random.nextBytes(iv)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
val ivBase64 = Base64.getEncoder().encodeToString(iv)
val encryptedMsg = cipher.doFinal(msg.toByteArray())
val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg)
return "$encryptedMsgBase64?iv=$ivBase64"
}
fun decryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
val sharedSecret = getSharedSecretNIP04(privateKey, pubKey)
return decryptNIP04(msg, sharedSecret)
}
fun decryptNIP04(msg: String, sharedSecret: ByteArray): String {
val parts = msg.split("?iv=")
val iv = parts[1].run { Base64.getDecoder().decode(this) }
val encryptedMsg = parts.first().run { Base64.getDecoder().decode(this) }
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
return String(cipher.doFinal(encryptedMsg))
}
fun encryptNIP24(msg: String, privateKey: ByteArray, pubKey: ByteArray): EncryptedInfo {
val sharedSecret = getSharedSecretNIP24(privateKey, pubKey)
return encryptNIP24(msg, sharedSecret)
}
fun encryptNIP24(msg: String, sharedSecret: ByteArray): EncryptedInfo {
val nonce = ByteArray(24)
random.nextBytes(nonce)
val cipher = SodiumAndroid().cryptoStreamXChaCha20Xor(
messageBytes = msg.toByteArray(),
nonce = nonce,
key = Key.fromBytes(sharedSecret)
)
val cipherBase64 = Base64.getEncoder().encodeToString(cipher)
val nonceBase64 = Base64.getEncoder().encodeToString(nonce)
return EncryptedInfo(
ciphertext = cipherBase64,
nonce = nonceBase64,
v = Nip44Version.XChaCha20.versionCode
)
}
fun decryptNIP24(encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String? {
val sharedSecret = getSharedSecretNIP24(privateKey, pubKey)
return decryptNIP24(encryptedInfo, sharedSecret)
}
fun decryptNIP24(encryptedInfo: EncryptedInfo, sharedSecret: ByteArray): String? {
return SodiumAndroid().cryptoStreamXChaCha20Xor(
messageBytes = Base64.getDecoder().decode(encryptedInfo.ciphertext),
nonce = Base64.getDecoder().decode(encryptedInfo.nonce),
key = Key.fromBytes(sharedSecret)
)?.decodeToString()
}
/**
* @return 32B shared secret
*/
fun getSharedSecretNIP24(privateKey: ByteArray, pubKey: ByteArray): ByteArray =
sha256(secp256k1.pubKeyTweakMul(Hex.decode("02") + pubKey, privateKey).copyOfRange(1, 33))
}
data class EncryptedInfo(val ciphertext: String, val nonce: String, val v: Int)
enum class Nip44Version(val versionCode: Int) {
Reserved(0),
XChaCha20(1)
}

View File

@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.quartz.events.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean {
return Character.charCount(this.codePointAt(pos)) == 2

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

@@ -3,8 +3,9 @@ package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import io.trbl.blurhash.BlurHash
import kotlin.math.roundToInt

View File

@@ -1,23 +0,0 @@
package com.vitorpamplona.amethyst.service
object HexValidator {
private fun isHex2(c: Char): Boolean {
return when (c) {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
else -> false
}
}
fun isHex(hex: String?): Boolean {
if (hex == null) return false
var isHex = true
for (c in hex.toCharArray()) {
if (!isHex2(c)) {
isHex = false
break
}
}
return isHex
}
}

View File

@@ -2,10 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
@@ -26,55 +23,46 @@ class Nip05Verifier() {
return null
}
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
}
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return
return@withContext
}
withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
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")
}
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
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")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server is up and if the address $nip05 is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
checkNotInMainThread()

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

@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.ui.note
import android.net.Uri
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
// Rename to the corect nip number when ready.
object Nip47 {

View File

@@ -1,24 +1,34 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.Client
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.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") {
lateinit var account: Account
@@ -47,6 +57,17 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountRelayListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.kind, StatusEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5
)
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
@@ -111,16 +132,80 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
)
fun createGiftWrapsToMeFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(GiftWrapEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
)
val accountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
}
override fun consume(event: Event, relay: Relay) {
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
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)
}
}
}
if (event is SealedGossipEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
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.
} else {
LocalCache.justConsume(event, relay)
}
}
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
@@ -133,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

@@ -4,11 +4,11 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null

View File

@@ -1,33 +1,36 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.PrivateDmEvent
object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
private var withRoom: ChatroomKey? = null
fun loadMessagesBetween(accountIn: Account, user: User) {
account = accountIn
withUser = user
private val latestEOSEs = EOSEAccount()
fun loadMessagesBetween(accountIn: Account, withRoom: ChatroomKey) {
this.account = accountIn
this.withRoom = withRoom
resetFilters()
}
fun createMessagesToMeFilter(): TypedFilter? {
val myPeer = withUser
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind, GiftWrapEvent.kind),
authors = listOf(myPeer.pubkeyHex),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
kinds = listOf(PrivateDmEvent.kind),
authors = myPeer.users.map { it },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
)
)
} else {
@@ -36,7 +39,7 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
}
fun createMessagesFromMeFilter(): TypedFilter? {
val myPeer = withUser
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
@@ -44,7 +47,8 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
tags = mapOf("p" to myPeer.users.map { it }),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
)
)
} else {
@@ -52,9 +56,14 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
}
}
val inandoutChannel = requestNewChannel()
val inandoutChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
}
override fun updateChannelFilters() {
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
inandoutChannel.typedFilters = listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter()
).ifEmpty { null }
}
}

View File

@@ -1,16 +1,15 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
lateinit var account: Account
@@ -21,7 +20,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind, GiftWrapEvent.kind),
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)

View File

@@ -1,11 +1,11 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
private var communityToWatch: AddressableNote? = null

View File

@@ -2,25 +2,29 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int)
private var eventCounter = mapOf<String, Counter>()
var changingFilters = AtomicBoolean()
fun printCounter() {
eventCounter.forEach {
@@ -39,7 +43,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
LocalCache.verifyAndConsume(event, relay)
consume(event, relay)
}
}
@@ -47,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."
@@ -55,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) {
@@ -77,6 +87,8 @@ abstract class NostrDataSource(val debugName: String) {
fun destroy() {
stop()
Client.unsubscribe(clientListener)
scope.cancel()
bundler.cancel()
}
open fun start() {
@@ -107,18 +119,19 @@ 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()
}
}
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
scope.launch(Dispatchers.IO) {
resetFiltersSuspend()
}
}
@@ -131,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.
@@ -162,6 +177,16 @@ 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()

View File

@@ -1,70 +1,83 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account
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

@@ -1,16 +1,17 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
private var geohashToWatch: String? = null
@@ -26,7 +27,7 @@ object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
hashToLoad
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
limit = 200
)
)

View File

@@ -1,17 +1,17 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
@@ -30,7 +30,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
limit = 200
)
)

View File

@@ -2,22 +2,23 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -54,12 +55,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 6))
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
@@ -73,6 +69,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
AudioHeaderEvent.kind,
PinListEvent.kind,
LiveActivitiesChatMessageEvent.kind,
LiveActivitiesEvent.kind
@@ -92,7 +89,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
@@ -112,7 +109,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
@@ -137,6 +134,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
LongTextNoteEvent.kind,
ClassifiedsEvent.kind,
HighlightEvent.kind,
AudioHeaderEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind,
CommunityPostApprovalEvent.kind

View File

@@ -1,12 +1,12 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
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.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
class NostrLnZapPaymentResponseDataSource(
private val fromServiceHex: String,
@@ -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,13 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import fr.acinq.secp256k1.Hex
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.*
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
private var searchString: String? = null
@@ -56,8 +56,9 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, BadgeDefinitionEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PollNoteEvent.kind, ChannelCreateEvent.kind
PeopleListEvent.kind, BookmarkListEvent.kind, AudioHeaderEvent.kind,
AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind,
ChannelCreateEvent.kind
),
search = mySearchString,
limit = 100

View File

@@ -1,20 +1,20 @@
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.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
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

@@ -2,11 +2,11 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.*
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
@@ -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

@@ -1,44 +1,71 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
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 createUserReportFilter(): 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(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 createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
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 ->
@@ -50,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(createUserFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).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

@@ -1,10 +1,10 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.*
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null
@@ -34,6 +34,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
RepostEvent.kind,
LongTextNoteEvent.kind,
AudioTrackEvent.kind,
AudioHeaderEvent.kind,
PinListEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind

View File

@@ -1,12 +1,12 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
lateinit var account: Account
@@ -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

@@ -2,20 +2,16 @@ package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.Bech32
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.toLnUrl
import kotlinx.coroutines.CoroutineScope
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.Job
import kotlinx.coroutines.launch
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() {
@@ -29,106 +25,67 @@ 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
}
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
}
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
private suspend fun fetchLightningAddressJson(lnaddress: String, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
return
return@withContext
}
try {
withContext(Dispatchers.IO) {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
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")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
}
}
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
}
}
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.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("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")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
e.printStackTrace()
onError("Could not resolve $url. Check if the server is up and if the lightning address $lnaddress is correct")
}
}
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
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 "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
}
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fetchLightningAddressJson(
lnaddress,
onSuccess = {
@@ -138,7 +95,15 @@ class LightningAddressResolver() {
)
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
suspend fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(
@@ -179,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

@@ -1,72 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
@Immutable
class ChatMessageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
/**
* Recepients intended to receive this conversation
*/
private fun recipientsPubKey() = tags.mapNotNull {
if (it.size > 1 && it[0] == "p") it[1] else null
}
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
companion object {
const val kind = 14
fun create(
msg: String,
to: List<String>? = null,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): ChatMessageEvent {
val content = msg
val tags = mutableListOf<List<String>>()
to?.forEach {
tags.add(listOf("p", it))
}
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it, "", "mention"))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
geohash?.let {
tags.add(listOf("g", it))
}
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, ClassifiedsEvent.kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return ChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -1,32 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
@Immutable
class DeletionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun deleteEvents() = tags.map { it[1] }
companion object {
const val kind = 5
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): DeletionEvent {
val content = ""
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val tags = deleteEvents.map { listOf("e", it) }
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return DeletionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -1,43 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
@Immutable
class EmojiPackSelectionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 10030
fun create(
listOfEmojiPacks: List<ATag>?,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): EmojiPackSelectionEvent {
val msg = ""
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
listOfEmojiPacks?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = CryptoUtils.sign(id, privateKey)
return EmojiPackSelectionEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}

View File

@@ -1,326 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.bytesUsedInMemory
import fr.acinq.secp256k1.Hex
import java.lang.reflect.Type
import java.math.BigDecimal
import java.util.*
@Immutable
open class Event(
val id: HexKey,
@SerializedName("pubkey") val pubKey: HexKey,
@SerializedName("created_at") val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
val content: String,
val sig: HexKey
) : EventInterface {
override fun countMemory(): Long {
return 12L +
id.bytesUsedInMemory() +
pubKey.bytesUsedInMemory() +
tags.sumOf { it.sumOf { it.bytesUsedInMemory() } } +
content.bytesUsedInMemory() +
sig.bytesUsedInMemory()
}
override fun id(): HexKey = id
override fun pubKey(): HexKey = pubKey
override fun createdAt(): Long = createdAt
override fun kind(): Int = kind
override fun tags(): List<List<String>> = tags
override fun content(): String = content
override fun sig(): HexKey = sig
override fun toJson(): String = gson.toJson(this)
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) }
override fun isSensitive() = tags.any {
(it.size > 0 && it[0].equals("content-warning", true)) ||
(it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
}
override fun zapraiserAmount() = tags.firstOrNull() {
(it.size > 1 && it[0] == "zapraiser")
}?.get(1)?.toLongOrNull()
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
override fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1]
val relay = it.getOrNull(2)
ATag.parse(aTagValue, relay)
}
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
override fun geohashes() = tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] }
override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) }
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
override fun isTaggedEvent(idHex: String) = tags.any { it.size > 1 && it[0] == "e" && it[1] == idHex }
override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
override fun isTaggedAddressableNotes(idHexes: Set<String>) = tags.any { it.size > 1 && it[0] == "a" && it[1] in idHexes }
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
override fun isTaggedGeoHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "g" && it[1].startsWith(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
override fun isTaggedGeoHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "g" && it[1].lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
override fun firstIsTaggedAddressableNote(addressableNotes: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1)
override fun isTaggedAddressableKind(kind: Int): Boolean {
val kindStr = kind.toString()
return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
}
override fun getTagOfAddressableKind(kind: Int): ATag? {
val kindStr = kind.toString()
val aTag = tags
.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
?.getOrNull(1)
?: return null
return ATag.parse(aTag, null)
}
override fun getPoWRank(): Int {
var rank = 0
for (i in 0..id.length) {
if (id[i] == '0') {
rank += 4
} else if (id[i] in '4'..'7') {
rank += 1
break
} else if (id[i] in '2'..'3') {
rank += 2
break
} else if (id[i] == '1') {
rank += 3
break
} else {
break
}
}
return rank
}
override fun getGeoHash(): String? {
return tags.firstOrNull { it.size > 1 && it[0] == "g" }?.get(1)?.ifBlank { null }
}
override fun getReward(): BigDecimal? {
return try {
tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
} catch (e: Exception) {
null
}
}
open fun toNIP19(): String {
return if (this is AddressableEvent) {
ATag(kind, pubKey, dTag(), null).toNAddr()
} else {
Nip19.createNEvent(id, pubKey, kind, null)
}
}
fun toNostrUri(): String {
return "nostr:${toNIP19()}"
}
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
override fun checkSignature() {
if (!id.contentEquals(generateId())) {
throw Exception(
"""|Unexpected ID.
| Event: ${toJson()}
| Actual ID: $id
| Generated: ${generateId()}
""".trimIndent()
)
}
if (!CryptoUtils.verifySignature(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
throw Exception("""Bad signature!""")
}
}
override fun hasValidSignature(): Boolean {
return try {
id.contentEquals(generateId()) && CryptoUtils.verifySignature(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
} catch (e: Exception) {
Log.e("Event", "Fail checking if event $id has a valid signature", e)
false
}
}
private fun generateId(): String {
val rawEvent = listOf(0, pubKey, createdAt, kind, tags, content)
// GSON decided to hardcode these replacements.
// They break Nostr's hash check.
// These lines revert their code.
// https://github.com/google/gson/issues/2295
val rawEventJson = gson.toJson(rawEvent)
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return CryptoUtils.sha256(rawEventJson.toByteArray()).toHexKey()
}
private class EventDeserializer : JsonDeserializer<Event> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): Event {
val jsonObject = json.asJsonObject
return Event(
id = jsonObject.get("id").asString,
pubKey = jsonObject.get("pubkey").asString,
createdAt = jsonObject.get("created_at").asLong,
kind = jsonObject.get("kind").asInt,
tags = jsonObject.get("tags").asJsonArray.map {
it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString }
},
content = jsonObject.get("content").asString,
sig = jsonObject.get("sig").asString
)
}
}
private class EventSerializer : JsonSerializer<Event> {
override fun serialize(
src: Event,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("id", src.id)
addProperty("pubkey", src.pubKey)
addProperty("created_at", src.createdAt)
addProperty("kind", src.kind)
add(
"tags",
JsonArray().also { jsonTags ->
src.tags.forEach { tag ->
jsonTags.add(
JsonArray().also { jsonTagElement ->
tag.forEach { tagElement ->
jsonTagElement.add(tagElement)
}
}
)
}
}
)
addProperty("content", src.content)
addProperty("sig", src.sig)
}
}
}
private class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): ByteArray = Hex.decode(json.asString)
}
private class ByteArraySerializer : JsonSerializer<ByteArray> {
override fun serialize(
src: ByteArray,
typeOfSrc: Type?,
context: JsonSerializationContext?
) = JsonPrimitive(src.toHexKey())
}
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(Event::class.java, EventSerializer())
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
.registerTypeAdapter(Request::class.java, RequestDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event {
return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig, lenient)
}
fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: List<List<String>>, content: String): ByteArray {
val rawEvent = listOf(
0,
pubKey,
createdAt,
kind,
tags,
content
)
// GSON decided to hardcode these replacements.
// They break Nostr's hash check.
// These lines revert their code.
// https://github.com/google/gson/issues/2295
val rawEventJson = gson.toJson(rawEvent)
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return CryptoUtils.sha256(rawEventJson.toByteArray())
}
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = TimeUtils.now()): Event {
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey).toHexKey()
return Event(id.toHexKey(), pubKey, createdAt, kind, tags, content, sig)
}
}
}
@Immutable
interface AddressableEvent {
fun dTag(): String
fun address(): ATag
}

View File

@@ -1,84 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.EncryptedInfo
import com.vitorpamplona.amethyst.service.relays.Client
@Immutable
class GiftWrapEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
fun cachedGossip(privKey: ByteArray): Event? {
val hex = privKey.toHexKey()
if (cachedInnerEvent.contains(hex)) return cachedInnerEvent[hex]
val myInnerEvent = unwrap(privKey = privKey)
cachedInnerEvent = cachedInnerEvent + Pair(hex, myInnerEvent)
return myInnerEvent
}
fun unwrap(privKey: ByteArray) = try {
plainContent(privKey)?.let { fromJson(it, Client.lenient) }
} catch (e: Exception) {
// Log.e("UnwrapError", "Couldn't Decrypt the content", e)
null
}
private fun plainContent(privKey: ByteArray): String? {
if (content.isBlank()) return null
return try {
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privKey, pubKey.hexToByteArray())
val toDecrypt = gson.fromJson<EncryptedInfo>(
content,
EncryptedInfo::class.java
)
return CryptoUtils.decryptNIP24(toDecrypt, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message}")
null
}
}
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
companion object {
const val kind = 1059
fun create(
event: Event,
recipientPubKey: HexKey,
createdAt: Long = TimeUtils.now()
): GiftWrapEvent {
val privateKey = CryptoUtils.privkeyCreate() // GiftWrap is always a random key
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, recipientPubKey.hexToByteArray())
val content = gson.toJson(
CryptoUtils.encryptNIP24(
gson.toJson(event),
sharedSecret
)
)
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(listOf("p", recipientPubKey))
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return GiftWrapEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -1,180 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.Bech32
import com.vitorpamplona.amethyst.service.CryptoUtils
import java.nio.charset.Charset
import java.security.SecureRandom
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
@Immutable
class LnZapRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient
private var privateZapEvent: Event? = null
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
fun getPrivateZapEvent(loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
if (privateZapEvent != null) return privateZapEvent
val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
privateZapEvent = decryptedEvent
return privateZapEvent
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return null
}
companion object {
const val kind = 9734
fun create(
originalNote: EventInterface,
relays: Set<String>,
privateKey: ByteArray,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = TimeUtils.now()
): LnZapRequestEvent {
var content = message
var privkey = privateKey
var pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
listOf("relays") + relays
)
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
if (pollOption != null && pollOption >= 0) {
tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString()))
}
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
tags = tags + listOf(listOf("anon", ""))
privkey = CryptoUtils.privkeyCreate()
pubKey = CryptoUtils.pubkeyCreate(privkey).toHexKey()
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().hexToByteArray())
tags = tags + listOf(listOf("anon", encryptedContent))
content = "" // make sure public content is empty, as the content is encrypted
privkey = encryptionPrivateKey // sign event with generated privkey
pubKey = CryptoUtils.pubkeyCreate(encryptionPrivateKey).toHexKey() // updated event with according pubkey
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privkey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(
userHex: String,
relays: Set<String>,
privateKey: ByteArray,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = TimeUtils.now()
): LnZapRequestEvent {
var content = message
var privkey = privateKey
var pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
privkey = CryptoUtils.privkeyCreate()
pubKey = CryptoUtils.pubkeyCreate(privkey).toHexKey()
tags = tags + listOf(listOf("anon", ""))
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.hexToByteArray())
tags = tags + listOf(listOf("anon", encryptedContent))
content = ""
privkey = encryptionPrivateKey
pubKey = CryptoUtils.pubkeyCreate(encryptionPrivateKey).toHexKey()
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privkey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
var str = privkey + id + createdAt.toString()
var strbyte = str.toByteArray(Charset.forName("utf-8"))
return CryptoUtils.sha256(strbyte)
}
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey)
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val keySpec = SecretKeySpec(sharedSecret, "AES")
val ivSpec = IvParameterSpec(iv)
var utf8message = msg.toByteArray(Charset.forName("utf-8"))
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
val encryptedMsg = cipher.doFinal(utf8message)
val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32)
val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32)
return encryptedMsgBech32 + "_" + ivBech32
}
private fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey)
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
throw IllegalArgumentException("Invalid shared secret size")
}
val parts = msg.split("_")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid message format")
}
val iv = parts[1].run { Bech32.decode(this).second }
val encryptedMsg = parts.first().run { Bech32.decode(this).second }
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0)))
try {
val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
return String(decryptedMsgBytes)
} catch (ex: BadPaddingException) {
throw IllegalArgumentException("Bad padding: ${ex.message}")
}
}
}
}

View File

@@ -1,32 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
class NIP24Factory {
fun createMsgNIP24(
msg: String,
to: List<HexKey>,
from: ByteArray
): List<GiftWrapEvent> {
val senderPublicKey = CryptoUtils.pubkeyCreate(from).toHexKey()
val senderMessage = ChatMessageEvent.create(
msg = msg,
to = to,
privateKey = from
)
return to.plus(senderPublicKey).map {
GiftWrapEvent.create(
event = SealedGossipEvent.create(
event = senderMessage,
encryptTo = it,
privateKey = from
),
recipientPubKey = it
)
}
}
}

View File

@@ -1,36 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
@Immutable
class RelayAuthEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1)
fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1)
companion object {
const val kind = 22242
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RelayAuthEvent {
val content = ""
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
listOf("relay", relay),
listOf("challenge", challenge)
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -1,122 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.EncryptedInfo
import com.vitorpamplona.amethyst.service.relays.Client
@Immutable
class SealedGossipEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
fun cachedGossip(privKey: ByteArray): Event? {
val hex = privKey.toHexKey()
if (cachedInnerEvent.contains(hex)) return cachedInnerEvent[hex]
val gossip = unseal(privKey = privKey)
val event = gossip?.mergeWith(this)
cachedInnerEvent = cachedInnerEvent + Pair(hex, event)
return event
}
fun unseal(privKey: ByteArray): Gossip? = try {
plainContent(privKey)?.let { gson.fromJson(it, Gossip::class.java) }
} catch (e: Exception) {
null
}
private fun plainContent(privKey: ByteArray): String? {
if (content.isBlank()) return null
return try {
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privKey, pubKey.hexToByteArray())
val toDecrypt = gson.fromJson<EncryptedInfo>(
content,
EncryptedInfo::class.java
)
return CryptoUtils.decryptNIP24(toDecrypt, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message}")
null
}
}
companion object {
const val kind = 13
fun create(
event: Event,
encryptTo: HexKey,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): SealedGossipEvent {
val gossip = Gossip.create(event)
return create(gossip, encryptTo, privateKey, createdAt)
}
fun create(
gossip: Gossip,
encryptTo: HexKey,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): SealedGossipEvent {
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, encryptTo.hexToByteArray())
val content = gson.toJson(
CryptoUtils.encryptNIP24(
gson.toJson(gossip),
sharedSecret
)
)
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = CryptoUtils.sign(id, privateKey)
return SealedGossipEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
class Gossip(
val id: HexKey?,
@SerializedName("pubkey")
val pubKey: HexKey?,
@SerializedName("created_at")
val createdAt: Long?,
val kind: Int?,
val tags: List<List<String>>?,
val content: String?
) {
fun mergeWith(event: SealedGossipEvent): Event {
val newPubKey = pubKey?.ifBlank { null } ?: event.pubKey
val newCreatedAt = if (createdAt != null && createdAt > 1000) createdAt else event.createdAt
val newKind = kind ?: 0
val newTags = (tags ?: emptyList()).plus(event.tags)
val newContent = content ?: ""
val newID = id?.ifBlank { null } ?: Event.generateId(newPubKey, newCreatedAt, newKind, newTags, newContent).toHexKey()
val sig = ""
return EventFactory.create(newID, newPubKey, newCreatedAt, newKind, newTags, newContent, sig, Client.lenient)
}
companion object {
fun create(event: Event): Gossip {
return Gossip(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content)
}
}
}

View File

@@ -1,8 +1,8 @@
package com.vitorpamplona.amethyst.service.model.zaps
package com.vitorpamplona.quartz.events.zaps
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
import com.vitorpamplona.quartz.events.LnZapEventInterface
object UserZaps {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {

View File

@@ -1,179 +0,0 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.bechToBytes
import com.vitorpamplona.amethyst.service.toNEvent
import java.util.regex.Pattern
object Nip19 {
enum class Type {
USER, NOTE, EVENT, RELAY, ADDRESS
}
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
@Immutable
data class Return(
val type: Type,
val hex: String,
val relay: String? = null,
val author: String? = null,
val kind: Int? = null,
val additionalChars: String = ""
)
fun uriToRoute(uri: String?): Return? {
if (uri == null) return null
try {
val matcher = nip19regex.matcher(uri)
if (!matcher.find()) {
return null
}
val uriScheme = matcher.group(1) // nostr:
val type = matcher.group(2) // npub1
val key = matcher.group(3) // bech32
val additionalChars = matcher.group(4) // additional chars
return parseComponents(uriScheme, type, key, additionalChars)
} catch (e: Throwable) {
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
}
return null
}
fun parseComponents(
uriScheme: String?,
type: String,
key: String?,
additionalChars: String?
): Return? {
return try {
val bytes = (type + key).bechToBytes()
val parsed = when (type.lowercase()) {
"npub1" -> npub(bytes)
"note1" -> note(bytes)
"nprofile1" -> nprofile(bytes)
"nevent1" -> nevent(bytes)
"nrelay1" -> nrelay(bytes)
"naddr1" -> naddr(bytes)
else -> null
}
parsed?.copy(additionalChars = additionalChars ?: "")
} catch (e: Throwable) {
Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
null
}
}
private fun npub(bytes: ByteArray): Return {
return Return(Type.USER, bytes.toHexKey())
}
private fun note(bytes: ByteArray): Return {
return Return(Type.NOTE, bytes.toHexKey())
}
private fun nprofile(bytes: ByteArray): Return? {
val tlv = Tlv.parse(bytes)
val hex = tlv.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
val relay = tlv.get(Tlv.Type.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
return Return(Type.USER, hex, relay)
}
private fun nevent(bytes: ByteArray): Return? {
val tlv = Tlv.parse(bytes)
val hex = tlv.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
val relay = tlv.get(Tlv.Type.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
val author = tlv.get(Tlv.Type.AUTHOR.id)
?.get(0)
?.toHexKey()
val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0)
?.let { Tlv.toInt32(it) }
return Return(Type.EVENT, hex, relay, author, kind)
}
private fun nrelay(bytes: ByteArray): Return? {
val relayUrl = Tlv.parse(bytes)
.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
return Return(Type.RELAY, relayUrl)
}
private fun naddr(bytes: ByteArray): Return? {
val tlv = Tlv.parse(bytes)
val d = tlv.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
val relay = tlv.get(Tlv.Type.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
val author = tlv.get(Tlv.Type.AUTHOR.id)
?.get(0)
?.toHexKey()
val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0)
?.let { Tlv.toInt32(it) }
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
}
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
val kind = kind?.toByteArray()
val author = author?.hexToByteArray()
val idHex = idHex.hexToByteArray()
val relay = relay?.toByteArray(Charsets.UTF_8)
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
if (relay != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
}
if (author != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author
}
if (kind != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
}
return fullArray.toNEvent()
}
}
fun Int.toByteArray(): ByteArray {
val bytes = ByteArray(4)
(0..3).forEach {
bytes[3 - it] = ((this ushr (8 * it)) and 0xFFFF).toByte()
}
return bytes
}

View File

@@ -1,36 +0,0 @@
package com.vitorpamplona.amethyst.service.nip19
import java.nio.ByteBuffer
import java.nio.ByteOrder
object Tlv {
enum class Type(val id: Byte) {
SPECIAL(0),
RELAY(1),
AUTHOR(2),
KIND(3);
}
fun toInt32(bytes: ByteArray): Int {
require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
}
fun parse(data: ByteArray): Map<Byte, List<ByteArray>> {
val result = mutableMapOf<Byte, MutableList<ByteArray>>()
var rest = data
while (rest.isNotEmpty()) {
val t = rest[0]
val l = rest[1]
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
if (v.size < l) continue
if (!result.containsKey(t)) {
result[t] = mutableListOf()
}
result[t]?.add(v)
}
return result
}
}

View File

@@ -5,55 +5,202 @@ import android.content.Context
import androidx.core.content.ContextCompat
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.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.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) {
fun consume(event: Event) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
if (LocalCache.notes[event.id] == null) {
// adds to database
LocalCache.verifyAndConsume(event, null)
suspend fun consume(event: GiftWrapEvent) {
if (!LocalCache.justVerify(event)) return
if (!notificationManager().areNotificationsEnabled()) return
val manager = notificationManager()
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
// 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)
}
}
}
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)
}
}
}
}
}
private fun notify(event: PrivateDmEvent) {
private fun unwrapAndConsume(event: Event, account: Account): Event? {
if (!LocalCache.justVerify(event)) return null
return when (event) {
is GiftWrapEvent -> {
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
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 -> {
LocalCache.justConsume(event, null)
event
}
}
}
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
val chatNote = LocalCache.notes[event.id] ?: return
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
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(
event.id,
content,
user,
userPicture,
noteUri,
applicationContext
)
}
}
}
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 messagingWith = acc.userProfile().privateChatrooms.keys.filter {
(
it.pubkeyHex in followingKeySet || acc.userProfile()
.hasSentMessagesTo(it)
) && !acc.isHidden(it)
}.toSet()
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
(
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}.toSet()
if (note.author in messagingWith) {
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()
@@ -64,49 +211,50 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
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)
}
}
private fun notificationManager(): NotificationManager {
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
}
}

View File

@@ -21,7 +21,7 @@ object NotificationUtils {
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
dmChannel = NotificationChannel(
@@ -41,7 +41,7 @@ object NotificationUtils {
return dmChannel!!
}
private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
if (zapChannel != null) return zapChannel!!
zapChannel = NotificationChannel(

View File

@@ -5,11 +5,9 @@ import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import kotlinx.coroutines.CoroutineScope
import com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -25,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)
@@ -38,7 +36,7 @@ class RegisterAccounts(
}
}
private fun postRegistrationEvent(events: List<RelayAuthEvent>) {
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
try {
val jsonObject = """{
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
@@ -64,12 +62,9 @@ class RegisterAccounts(
}
}
fun go(notificationToken: String) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
}

View File

@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -17,16 +17,6 @@ import java.util.UUID
* Events are stored with their respective persona.
*/
object Client : RelayPool.Listener {
/**
* Lenient mode:
*
* true: For maximum compatibility. If you want to play ball with sloppy counterparts, use
* this.
* false: For developers who want to make protocol compliant counterparts. If your software
* produces events that fail to deserialize in strict mode, you should probably fix
* something.
**/
var lenient: Boolean = false
private var listeners = setOf<Listener>()
private var relays = Constants.convertDefaultRelays()
private var subscriptions = mapOf<String, List<TypedFilter>>()

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

@@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.vitorpamplona.quartz.events.Event
class JsonFilter(
val ids: List<String>? = null,
@@ -15,51 +12,64 @@ class JsonFilter(
val limit: Int? = null,
val search: String? = null
) {
fun toJson(forRelay: String? = null): String {
val jsonObject = JsonObject()
ids?.run {
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
}
authors?.run {
jsonObject.add("authors", JsonArray().apply { authors.forEach { add(it) } })
}
kinds?.run {
jsonObject.add("kinds", JsonArray().apply { kinds.forEach { add(it) } })
}
tags?.run {
entries.forEach { kv ->
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
val factory = Event.mapper.nodeFactory
val filter = factory.objectNode().apply {
ids?.run {
put(
"ids",
factory.arrayNode(ids.size).apply {
ids.forEach { add(it) }
}
)
}
}
since?.run {
if (!isEmpty()) {
if (forRelay != null) {
val relaySince = get(forRelay)
if (relaySince != null) {
jsonObject.addProperty("since", relaySince.time)
authors?.run {
put(
"authors",
factory.arrayNode(authors.size).apply {
authors.forEach { add(it) }
}
} else {
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
)
}
kinds?.run {
put(
"kinds",
factory.arrayNode(kinds.size).apply {
kinds.forEach { add(it) }
}
jsonObject.add("since", jsonObjectSince)
)
}
tags?.run {
entries.forEach { kv ->
put(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply {
kv.value.forEach { add(it) }
}
)
}
}
since?.run {
if (!isEmpty()) {
if (forRelay != null) {
val relaySince = get(forRelay)
if (relaySince != null) {
put("since", relaySince.time)
}
} else {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
}
put("since", jsonObjectSince)
}
}
}
until?.run { put("until", until) }
limit?.run { put("limit", limit) }
search?.run { put("search", search) }
}
until?.run {
jsonObject.addProperty("until", until)
}
limit?.run {
jsonObject.addProperty("limit", limit)
}
search?.run {
jsonObject.addProperty("search", search)
}
return gson.toJson(jsonObject)
}
companion object {
val gson: Gson = GsonBuilder().create()
return Event.mapper.writeValueAsString(filter)
}
}

View File

@@ -1,13 +1,13 @@
package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.events.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.TimeUtils
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -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,27 +182,29 @@ 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()
}
fun processNewRelayMessage(newMessage: String) {
val msgArray = Event.gson.fromJson(newMessage, JsonElement::class.java).asJsonArray
val type = msgArray[0].asString
val channel = msgArray[1].asString
val msgArray = Event.mapper.readTree(newMessage)
val type = msgArray.get(0).asText()
val channel = msgArray.get(1).asText()
when (type) {
"EVENT" -> {
val event = Event.fromJson(msgArray[2], Client.lenient)
val event = Event.fromJson(msgArray.get(2))
// Log.w("Relay", "Relay onEVENT $url, $channel")
listeners.forEach {
@@ -215,12 +224,12 @@ class Relay(
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
Log.w("Relay", "Relay on OK $url, ${msgArray[1].asString}, ${msgArray[2].asBoolean}, ${msgArray[3].asString}")
it.onSendResponse(this@Relay, msgArray[1].asString, msgArray[2].asBoolean, msgArray[3].asString)
Log.w("Relay", "Relay on OK $url, ${msgArray[1].asText()}, ${msgArray[2].asBoolean()}, ${msgArray[3].asText()}")
it.onSendResponse(this@Relay, msgArray[1].asText(), msgArray[2].asBoolean(), msgArray[3].asText())
}
"AUTH" -> listeners.forEach {
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
it.onAuth(this@Relay, msgArray[1].asString)
it.onAuth(this@Relay, msgArray[1].asText())
}
else -> listeners.forEach {
// Log.w("Relay", "Relay something else $url, $channel")
@@ -239,6 +248,7 @@ class Relay(
socket?.close(1000, "Normal close")
socket = null
isReady = false
usingCompression = false
afterEOSE = false
}
@@ -284,16 +294,36 @@ class Relay(
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
if (write) {
if (isConnected()) {
if (isReady) {
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
if (write) {
if (signedEvent !is RelayAuthEvent) {
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
}
}
}
}
}
}
@@ -343,7 +373,3 @@ class Relay(
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
}
}
fun String.bytesUsedInMemory(): Int {
return (8 * ((((this.length) * 2) + 45) / 8))
}

View File

@@ -1,24 +1,26 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
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.
*/
object RelayPool : Relay.Listener {
val scope = CoroutineScope(Job() + Dispatchers.IO)
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
}
@@ -83,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) {
@@ -116,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) {
@@ -132,20 +138,15 @@ object RelayPool : Relay.Listener {
listeners.forEach { it.onAuth(relay, challenge) }
}
// Observers line up here.
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
scope.launch {
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,8 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.fasterxml.jackson.databind.JsonNode
import com.vitorpamplona.quartz.events.Event
import java.util.UUID
data class Subscription(
@@ -16,15 +15,24 @@ data class Subscription(
}
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
return Event.mapper.writeValueAsString(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.addProperty("id", id)
typedFilters?.run {
jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } })
fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
put("id", id)
typedFilters?.also { filters ->
put(
"typedFilters",
factory.arrayNode(filters.size).apply {
filters.forEach { filter ->
add(filter.toJsonObject())
}
}
)
}
}
return jsonObject
}
}

View File

@@ -1,8 +1,8 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ArrayNode
import com.vitorpamplona.quartz.events.Event
class TypedFilter(
val types: Set<FeedType>,
@@ -10,54 +10,74 @@ class TypedFilter(
) {
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
return Event.mapper.writeValueAsString(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.add("types", typesToJson(types))
jsonObject.add("filter", filterToJson(filter))
return jsonObject
fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
put("types", typesToJson(types))
put("filter", filterToJson(filter))
}
}
fun typesToJson(types: Set<FeedType>): JsonArray {
return JsonArray().apply { types.forEach { add(it.name.lowercase()) } }
fun typesToJson(types: Set<FeedType>): ArrayNode {
val factory = Event.mapper.nodeFactory
return factory.arrayNode(types.size).apply {
types.forEach { add(it.name.lowercase()) }
}
}
fun filterToJson(filter: JsonFilter): JsonObject {
val jsonObject = JsonObject()
filter.ids?.run {
jsonObject.add("ids", JsonArray().apply { filter.ids.forEach { add(it) } })
}
filter.authors?.run {
jsonObject.add("authors", JsonArray().apply { filter.authors.forEach { add(it) } })
}
filter.kinds?.run {
jsonObject.add("kinds", JsonArray().apply { filter.kinds.forEach { add(it) } })
}
filter.tags?.run {
entries.forEach { kv ->
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
fun filterToJson(filter: JsonFilter): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
filter.ids?.run {
put(
"ids",
factory.arrayNode(filter.ids.size).apply {
filter.ids.forEach { add(it) }
}
)
}
}
/*
Does not include since in the json comparison
filter.since?.run {
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
filter.authors?.run {
put(
"authors",
factory.arrayNode(filter.authors.size).apply {
filter.authors.forEach { add(it) }
}
)
}
jsonObject.add("since", jsonObjectSince)
}*/
filter.until?.run {
jsonObject.addProperty("until", filter.until)
filter.kinds?.run {
put(
"kinds",
factory.arrayNode(filter.kinds.size).apply {
filter.kinds.forEach { add(it) }
}
)
}
filter.tags?.run {
entries.forEach { kv ->
put(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply {
kv.value.forEach { add(it) }
}
)
}
}
/*
Does not include since in the json comparison
filter.since?.run {
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
}
jsonObject.add("since", jsonObjectSince)
}*/
filter.until?.run { put("until", filter.until) }
filter.limit?.run { put("limit", filter.limit) }
filter.search?.run { put("search", filter.search) }
}
filter.limit?.run {
jsonObject.addProperty("limit", filter.limit)
}
filter.search?.run {
jsonObject.addProperty("search", filter.search)
}
return jsonObject
}
}

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
@@ -17,24 +18,14 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.BuildConfig
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.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
import com.vitorpamplona.amethyst.ui.navigation.Route
@@ -44,6 +35,13 @@ import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -54,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()
@@ -69,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)
}
@@ -85,10 +88,9 @@ 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)
Client.lenient = true
}
@OptIn(DelicateCoroutinesApi::class)
@@ -100,18 +102,25 @@ class MainActivity : AppCompatActivity() {
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start(this@MainActivity)
if (ServiceManager.shouldPauseService) {
ServiceManager.start(this@MainActivity)
}
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
GlobalScope.launch(Dispatchers.IO) {
PushNotificationUtils.init(LocalPreferences.allSavedAccounts())
}
}
override fun onPause() {
if (BuildConfig.DEBUG) {
debugState(this)
}
ServiceManager.cleanObservers()
// if (BuildConfig.DEBUG) {
debugState(this)
// }
ServiceManager.pause()
if (ServiceManager.shouldPauseService) {
ServiceManager.pause()
}
super.onPause()
}
@@ -131,7 +140,7 @@ class MainActivity : AppCompatActivity() {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) {
ServiceManager.cleanUp()
ServiceManager.trimMemory()
}
}
@@ -143,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)
}
}
}
@@ -156,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")
@@ -173,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()
}
}
}
}
@@ -181,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/*"))
}
}
@@ -202,7 +222,9 @@ fun uriToRoute(uri: String?): String? {
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> {
if (nip19.kind == PrivateDmEvent.kind) {
"Room/${nip19.author}"
nip19.author?.let {
"RoomByAuthor/$it"
}
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
"Channel/${nip19.hex}"
} else {

View File

@@ -141,6 +141,7 @@ object ImageUploader {
fun NIP98Header(url: String, method: String, body: String): String {
val noteJson = account.createHTTPAuthorization(url, method, body)?.toJson() ?: ""
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
return "Nostr " + encodedNIP98Event
}

View File

@@ -67,6 +67,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChatroomKey
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
@@ -118,7 +120,7 @@ fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () ->
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
onClose()
@@ -300,6 +302,7 @@ private fun RenderSearchResults(
if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsState()
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
val scope = rememberCoroutineScope()
Row(
modifier = Modifier
@@ -320,7 +323,12 @@ private fun RenderSearchResults(
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(item, accountViewModel) {
nav("Room/${item.pubkeyHex}")
scope.launch(Dispatchers.IO) {
val withKey = ChatroomKey(persistentSetOf(item.pubkeyHex))
accountViewModel.userProfile().createChatroom(withKey)
nav("Room/${withKey.hashCode()}")
}
searchBarViewModel.clear()
}
}

View File

@@ -7,14 +7,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -28,8 +29,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) {
@@ -44,7 +43,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp)
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
@@ -52,19 +51,15 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.clear()
onClose()
})
val scope = rememberCoroutineScope()
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.create()
onClose()
}
postViewModel.create()
onClose()
},
postViewModel.channelName.value.text.isNotBlank()
)

View File

@@ -3,8 +3,11 @@ package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.PublicChatChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class NewChannelViewModel : ViewModel() {
private var account: Account? = null
@@ -25,24 +28,26 @@ class NewChannelViewModel : ViewModel() {
}
fun create() {
this.account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text
)
} else {
account.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
viewModelScope.launch(Dispatchers.IO) {
account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text
)
} else {
account.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
}
}
}
clear()
clear()
}
}
fun clear() {

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
@@ -57,7 +58,7 @@ import kotlinx.coroutines.withContext
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val account = accountViewModel.accountLiveData.value?.account ?: return
val account = accountViewModel.account
val resolver = LocalContext.current.contentResolver
val context = LocalContext.current
@@ -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
},
@@ -123,7 +118,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})
@@ -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)
}
}
}
@@ -241,6 +237,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
postViewModel.galleryUri?.let {
VideoView(
videoUri = it.toString(),
roundedCorner = false,
accountViewModel = accountViewModel
)
}

View File

@@ -1,13 +1,13 @@
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.KeyPair
import com.vitorpamplona.amethyst.service.bechToBytes
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.toNpub
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.toNpub
class NewMessageTagger(
var message: String,
@@ -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

@@ -18,24 +18,36 @@ import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationUtil
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.AddressableEvent
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
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
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
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
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
FORWARD_ZAPS,
TO_USERS
}
@Stable
open class NewPostViewModel() : ViewModel() {
var account: Account? = null
var requiresNIP24: Boolean = false
var originalNote: Note? = null
var mentions by mutableStateOf<List<User>?>(null)
@@ -48,7 +60,12 @@ open class NewPostViewModel() : ViewModel() {
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
var userSuggestionsMainMessage: Boolean? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
// DMs
var wantsDirectMessage by mutableStateOf(false)
var toUsers by mutableStateOf(TextFieldValue(""))
var subject by mutableStateOf(TextFieldValue(""))
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
@@ -58,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
@@ -73,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
@@ -91,6 +112,9 @@ open class NewPostViewModel() : ViewModel() {
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(null)
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -133,7 +157,7 @@ open class NewPostViewModel() : ViewModel() {
wantsToAddGeoHash = false
wantsZapraiser = false
zapRaiserAmount = null
forwardZapTo = null
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
this.account = account
@@ -143,11 +167,18 @@ open class NewPostViewModel() : ViewModel() {
val tagger = NewMessageTagger(message.text, mentions, replyTos, originalNote?.channelHex())
tagger.run()
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null)
toUsersTagger.run()
val dmUsers = toUsersTagger.mentions
val zapReceiver = if (wantsForwardZapTo) {
if (forwardZapTo != null) {
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
} else {
forwardZapToEditting.text
forwardZapTo?.items?.map {
ZapSplitSetup(
lnAddressOrPubKeyHex = it.key.pubkeyHex,
relay = it.key.relaysBeingUsed.keys.firstOrNull(),
weight = it.percentage.toDouble(),
isLnAddress = false
)
}
} else {
null
@@ -162,23 +193,7 @@ open class NewPostViewModel() : ViewModel() {
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
if (wantsPoll) {
account?.sendPoll(
tagger.message,
tagger.replyTos,
tagger.mentions,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash
)
} else if (originalNote?.channelHex() != null) {
if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
} else {
@@ -186,28 +201,85 @@ open class NewPostViewModel() : ViewModel() {
}
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
} else {
// adds markers
val rootId =
(originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root
?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }?.idHex // if it has loaded events with zero replies in the reply list
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
val replyId = originalNote?.idHex
} else if (originalNote?.event is ChatMessageEvent) {
val receivers = (originalNote?.event as ChatMessageEvent).recipientsPubKey().plus(originalNote?.author?.pubkeyHex).filterNotNull().toSet().toList()
account?.sendPost(
account?.sendNIP24PrivateMessage(
message = tagger.message,
replyTo = tagger.replyTos,
toUsers = receivers,
subject = subject.text.ifBlank { null },
replyingTo = originalNote!!,
mentions = tagger.mentions,
tags = null,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions,
relayList = relayList,
geohash = geoHash
)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip24 || dmUsers.size > 1) {
account?.sendNIP24PrivateMessage(
message = tagger.message,
toUsers = dmUsers.map { it.pubkeyHex },
subject = subject.text.ifBlank { null },
replyingTo = tagger.replyTos?.firstOrNull(),
mentions = tagger.mentions,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash
)
} else {
account?.sendPrivateMessage(
message = tagger.message,
toUser = dmUsers.first().pubkeyHex,
replyingTo = originalNote,
mentions = tagger.mentions,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash
)
}
} else {
if (wantsPoll) {
account?.sendPoll(
tagger.message,
tagger.replyTos,
tagger.mentions,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash
)
} else {
// adds markers
val rootId =
(originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root
?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }?.idHex // if it has loaded events with zero replies in the reply list
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
val replyId = originalNote?.idHex
account?.sendPost(
message = tagger.message,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
tags = null,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions,
relayList = relayList,
geohash = geoHash
)
}
}
cancel()
@@ -267,11 +339,16 @@ open class NewPostViewModel() : ViewModel() {
open fun cancel() {
message = TextFieldValue("")
toUsers = TextFieldValue("")
subject = TextFieldValue("")
contentToAddUrl = null
urlPreview = null
isUploadingImage = false
mentions = null
wantsDirectMessage = false
wantsPoll = false
zapRecipients = mutableStateListOf<HexKey>()
pollOptions = newStateMapPollOptions()
@@ -287,7 +364,7 @@ open class NewPostViewModel() : ViewModel() {
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
forwardZapTo = null
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
userSuggestions = emptyList()
@@ -316,7 +393,7 @@ open class NewPostViewModel() : ViewModel() {
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = true
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
@@ -331,13 +408,38 @@ open class NewPostViewModel() : ViewModel() {
}
}
open fun updateZapForwardTo(it: TextFieldValue) {
forwardZapToEditting = it
open fun updateToUsers(it: TextFieldValue) {
toUsers = it
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = false
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.reversed()
}
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
}
}
}
open fun updateSubject(it: TextFieldValue) {
subject = it
}
open fun updateZapForwardTo(it: TextFieldValue) {
forwardZapToEditting = it
if (it.selection.collapsed) {
val lastWord = it.text
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
if (lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
@@ -357,7 +459,7 @@ open class NewPostViewModel() : ViewModel() {
open fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
if (userSuggestionsMainMessage == true) {
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
@@ -366,7 +468,10 @@ open class NewPostViewModel() : ViewModel() {
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
} else {
} 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()}"
@@ -375,6 +480,15 @@ 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
val wordToInsert = "@${item.pubkeyNpub()}"
toUsers = TextFieldValue(
toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
}
@@ -389,7 +503,11 @@ open class NewPostViewModel() : ViewModel() {
}
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
contentToAddUrl == null
}
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
@@ -498,6 +616,62 @@ open class NewPostViewModel() : ViewModel() {
location = null
locUtil = null
}
fun toggleNIP04And24() {
if (requiresNIP24) {
nip24 = true
} else {
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,10 +4,11 @@ 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.model.ContactListEvent
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
import com.vitorpamplona.quartz.events.ContactListEvent
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -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)
@@ -67,7 +69,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.clear()
onClose()
})
@@ -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

@@ -10,11 +10,10 @@ import androidx.lifecycle.viewModelScope
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.GitHubIdentity
import com.vitorpamplona.amethyst.service.model.MastodonIdentity
import com.vitorpamplona.amethyst.service.model.TwitterIdentity
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import id.zelory.compressor.Compressor.compress
import com.vitorpamplona.quartz.events.GitHubIdentity
import com.vitorpamplona.quartz.events.MastodonIdentity
import com.vitorpamplona.quartz.events.TwitterIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@@ -125,7 +124,6 @@ class NewUserMetadataViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
}
clear()
}
@@ -184,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)

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