Compare commits

...

566 Commits

Author SHA1 Message Date
Vitor Pamplona
f40b6c394c v0.46.2 2023-05-14 17:22:22 -04:00
Vitor Pamplona
f84501e40f Improvements to Poll Caching system: Fixes https://github.com/vitorpamplona/amethyst/issues/406 2023-05-14 17:03:46 -04:00
Vitor Pamplona
6842d12b39 Turn FollowList into a ViewModel and a LocalCache LiveData into a SharedFlow (it was killing some coroutines that were supposed to finish) 2023-05-13 12:49:48 -04:00
Vitor Pamplona
3e69b81d81 Moves aspect ratio to the appropriate composable 2023-05-13 12:46:57 -04:00
Vitor Pamplona
ea4e3e75d6 Formatting 2023-05-13 12:46:34 -04:00
Vitor Pamplona
5db04ef2b0 Allows the RichTextViewer to render before resolving urls. 2023-05-13 12:46:11 -04:00
Vitor Pamplona
b51186de30 updated secp lib 2023-05-12 17:18:56 -04:00
Vitor Pamplona
8fc0f5a8b8 Removing shadowed variable 2023-05-12 16:50:26 -04:00
Vitor Pamplona
69744db53b Merge remote-tracking branch 'origin/HEAD' 2023-05-12 14:17:31 -04:00
Vitor Pamplona
db9a0ebfa1 Protects against exceptions when creating keys with invalid bytearrays. 2023-05-12 14:13:08 -04:00
Vitor Pamplona
38ebeded1c Merge pull request #402 from ZsZolee/main
TOR Hungarian translation
2023-05-12 13:40:41 -04:00
Vitor Pamplona
2b8fafc123 Merge pull request #403 from judemont/main
French translation for Android description.
2023-05-12 13:40:24 -04:00
Vitor Pamplona
fc2408b526 Correcting NPE from failing to load the thumbnail 2023-05-12 13:32:08 -04:00
JdM
6723806534 French translation for android description. 2023-05-12 17:26:11 +02:00
Zoltan
93c3a49ecf TOR Hungarian translation 2023-05-12 14:04:52 +02:00
Vitor Pamplona
66b84c0dea 0.46.1 2023-05-11 18:30:18 -04:00
Vitor Pamplona
a7063042fb Formatting 2023-05-11 18:15:56 -04:00
Vitor Pamplona
aa60367c72 Doesn't update if it returned the same reference for the list. 2023-05-11 18:15:48 -04:00
Vitor Pamplona
19a58bb669 correctly refreshes the screen 2023-05-11 17:39:31 -04:00
Vitor Pamplona
55aafffb37 Remove logs 2023-05-11 17:39:21 -04:00
Vitor Pamplona
9b92dfca5e Isolating state changes in the profile page. 2023-05-11 17:31:32 -04:00
Vitor Pamplona
3ae75a3333 Adds FAB column to create new channels and join channels/users 2023-05-11 16:42:05 -04:00
Vitor Pamplona
9ebb3fbefc Improving reply screen to clarify which users will be notified. 2023-05-11 15:26:09 -04:00
Vitor Pamplona
96d66646b4 Displaying lists as Notes 2023-05-11 14:39:05 -04:00
Vitor Pamplona
f202422ff1 hasValidSignature crashes in some weirdly formed events. 2023-05-11 10:12:01 -04:00
Vitor Pamplona
f02e18ff9b Fix background color from remember calls 2023-05-11 09:32:33 -04:00
Vitor Pamplona
b72b0b028f v0.46.0 2023-05-11 09:19:27 -04:00
Vitor Pamplona
eb9be2108d Adjustments to Tor/Orbot screens 2023-05-11 09:18:17 -04:00
Vitor Pamplona
875eb0df78 Merge pull request #357 from greenart7c3/main
Add option to setup connection through Orbot
2023-05-11 08:33:33 -04:00
Vitor Pamplona
ea8a665bc7 Merge pull request #401 from Radiokot/fix/actualize_ru_uk_23_05_10
Actualize Russian and Ukrainian translations
2023-05-11 08:33:11 -04:00
Vitor Pamplona
142f850eb7 Merge pull request #400 from Bardesss/patch-5
Update strings.xml (NL)
2023-05-11 08:33:04 -04:00
Vitor Pamplona
9f12fffd0d Merge pull request #399 from malimahda/main
values-fa/strings
2023-05-11 08:32:46 -04:00
Vitor Pamplona
28a1f0d6da v0.45.1 2023-05-10 19:36:07 -04:00
Vitor Pamplona
b1de36f423 Trying to improve rendering by allowing recompositions to perform layout faster. 2023-05-10 19:08:56 -04:00
Oleg Koretsky
ff54d581ae Actualize Russian and Ukrainian translations 2023-05-10 22:57:41 +03:00
Vitor Pamplona
8fcd05a6bb avoids cutting the first paragraph of notifcations 2023-05-10 14:03:05 -04:00
Vitor Pamplona
434c1e9af3 Don't try to decrypt if message is blank 2023-05-10 14:02:53 -04:00
Vitor Pamplona
a62f2e44d0 Only calls invalidate if things change 2023-05-10 14:02:38 -04:00
Vitor Pamplona
aa0ace5014 Delaying a bit more the average update filter for relays. 2023-05-10 14:02:05 -04:00
greenart7c3
ee4c54aa27 use httpclient.getHttpClient 2023-05-10 14:47:46 -03:00
greenart7c3
7ff408bfb9 Merge branch 'main' into main 2023-05-10 14:42:37 -03:00
Vitor Pamplona
c1d127ac1e Fixing blank messages and Zapped DM content 2023-05-10 13:31:25 -04:00
Vitor Pamplona
0b2e5de752 Uppercase 2023-05-10 13:30:52 -04:00
Vitor Pamplona
920ce54fe3 v0.45.0 2023-05-10 11:20:28 -04:00
Vitor Pamplona
c87b246a4e Fixing tests 2023-05-10 11:19:32 -04:00
Vitor Pamplona
b8bc370bc1 Support for Push Notifications in the PlayStore build 2023-05-10 11:16:47 -04:00
L
a8069f7a3e Update strings.xml 2023-05-10 16:17:01 +03:30
L
ca9b7adfa0 Update strings.xml
a few words
2023-05-10 15:21:40 +03:30
L
3c3b6544cf Update strings.xml
changed a couple of words
2023-05-10 15:02:28 +03:30
Bardesss
910849b3c7 Update strings.xml (NL)
Updated translation
2023-05-10 08:55:43 +00:00
L
411ba0f137 Merge pull request #1 from believethehype/persian_translation
fixed path to Persian translation file
2023-05-09 23:02:36 +03:30
Believethehype
d48a15bfb3 Update strings.xml 2023-05-09 21:15:55 +02:00
Believethehype
489941106b fixed path of persian translation 2023-05-09 21:14:50 +02:00
Believethehype
a0391fb8a5 Merge branch 'pr/1' into persian_translation 2023-05-09 21:11:40 +02:00
Vitor Pamplona
633be54dd4 Updates dependencies 2023-05-09 13:20:59 -04:00
Vitor Pamplona
470281fa87 Filters lists out of the user's profile 2023-05-09 12:58:56 -04:00
Vitor Pamplona
3287196243 updating gradle plugin 2023-05-09 11:10:53 -04:00
Vitor Pamplona
bb8bb16370 Better regex for nostr:n.. citations in text. 2023-05-09 11:10:39 -04:00
Vitor Pamplona
4325975686 v0.44.0 2023-05-08 16:20:40 -04:00
Vitor Pamplona
53ec9d777f - Moving postValue to IO thread
- launching navigate in a thread
2023-05-08 16:10:16 -04:00
Vitor Pamplona
3e6e7d4863 - Checks if the file still exists before discarding a duplicate event.
- Adding a per list EOSE
2023-05-08 15:34:59 -04:00
Vitor Pamplona
81290f2b26 Moving follows to the known list. 2023-05-08 14:58:39 -04:00
Vitor Pamplona
4dcf38c492 Fixing tab sliders from jumping from one state to the other 2023-05-08 14:46:23 -04:00
Vitor Pamplona
04c1300317 Adding List Choice to Notifications 2023-05-08 11:04:37 -04:00
greenart7c3
0d051117b2 add toast message to strings.xml 2023-05-08 11:03:17 -03:00
greenart7c3
d31eca1b42 Merge branch 'main' into main 2023-05-08 10:48:48 -03:00
greenart7c3
a503a1b218 show orbot dialog at login 2023-05-08 10:45:10 -03:00
Vitor Pamplona
70890def82 v0.43.2 2023-05-08 09:43:31 -04:00
Vitor Pamplona
d0eadcf30d Formatting 2023-05-08 09:39:54 -04:00
Vitor Pamplona
ae268cc197 Fixing NostrFiles dev downloader 2023-05-08 09:39:44 -04:00
L
0bf317a818 Update strings.xml 2023-05-08 12:01:21 +03:30
L
915f7c07e8 Update strings.xml 2023-05-08 10:40:59 +03:30
L
69a1302404 Update strings.xml 2023-05-08 09:47:05 +03:30
Vitor Pamplona
d6d273e34c v0.43.1 2023-05-07 21:46:01 -04:00
Vitor Pamplona
462c73341f Fixes cutting text in the wrong language 2023-05-07 21:45:38 -04:00
Vitor Pamplona
aa07fbfc94 Adjusting NostrFilesDev to the latest API 2023-05-07 18:47:39 -04:00
Vitor Pamplona
59d7f7cc70 Merge pull request #396 from believethehype/nostrfilesdev
Added Nostrfiles.dev Image hoster Backend Code
2023-05-07 18:42:39 -04:00
Vitor Pamplona
950eefa4e3 Merge branch 'main' into nostrfilesdev 2023-05-07 18:42:09 -04:00
Vitor Pamplona
f2bbdd44a6 Removing file transform function 2023-05-07 18:40:24 -04:00
Vitor Pamplona
cb09a26ef5 Refactoring the nostr.build uploader 2023-05-07 18:37:33 -04:00
L
43156e3e11 Update strings.xml 2023-05-07 23:51:47 +03:30
Vitor Pamplona
eba14cf9f1 v0.43.0 2023-05-07 16:14:18 -04:00
Vitor Pamplona
13de5973ca Improving performance of the RichText Viewer 2023-05-07 16:12:28 -04:00
Vitor Pamplona
936f750826 Moving private messages and channel loading to a thread. 2023-05-07 16:11:40 -04:00
L
faf215110a Update strings.xml 2023-05-07 23:34:27 +03:30
L
839e00ae36 Update strings.xml 2023-05-07 22:50:37 +03:30
L
b829a42d76 Update strings.xml 2023-05-07 22:45:58 +03:30
L
e1c3191f31 Update strings.xml 2023-05-07 22:18:25 +03:30
Vitor Pamplona
f44549de41 Loading Users and Notes in Launched Effects only because of @Synchronized in the getOrCreate methods in LocalCache 2023-05-07 12:51:12 -04:00
Vitor Pamplona
1c0fcc4ab8 Solves NPE when loading an invalid npub in a citation 2023-05-07 12:50:17 -04:00
Vitor Pamplona
fcc6793244 Limiting AuthorGalleries to 50 people 2023-05-07 08:53:52 -04:00
Vitor Pamplona
dea35552d5 Calculating ZapAmount and converting to the screen before exiting the IO thread 2023-05-06 22:27:00 -04:00
Vitor Pamplona
b093873eb0 Avoids trying to decrypt a zap that was not sent by the author. 2023-05-06 22:17:43 -04:00
Vitor Pamplona
e5de2c45de Moving withContext to scope.launch to avoid waiting the results. 2023-05-06 21:58:25 -04:00
Vitor Pamplona
70d8d9e2e0 formatting 2023-05-06 20:59:24 -04:00
Vitor Pamplona
dc0fe75b3d Removing crossfades that might reduce the loading speed 2023-05-06 20:59:09 -04:00
Vitor Pamplona
25a34dd2e8 formatting 2023-05-06 20:03:04 -04:00
Vitor Pamplona
90227e1cdc Refactoring NIP05 verification 2023-05-06 20:02:54 -04:00
Vitor Pamplona
bba45dd0a7 Moving clickable to Row and not AsyncImage because it's faster 2023-05-06 19:19:57 -04:00
Vitor Pamplona
fdca0af97b Improving performance of the UserNameDisplay by making variables immutable 2023-05-06 19:18:15 -04:00
Vitor Pamplona
795120e932 Improving performance of Badge events 2023-05-06 19:17:39 -04:00
Vitor Pamplona
79da9cee01 NoteCompose refactoring 2023-05-06 11:49:53 -04:00
Vitor Pamplona
fbbb9b0d72 Increasing sensitivity for Language identification options 2023-05-06 09:53:44 -04:00
Vitor Pamplona
97d56d61d1 Caching Private Zap decryption 2023-05-06 09:46:25 -04:00
Vitor Pamplona
f3316f53ea Renames Hex.toByteArray() to avoid confusing with String.toByteArray() 2023-05-06 09:24:39 -04:00
Vitor Pamplona
e3ccf71a84 v0.42.5 2023-05-05 18:50:19 -04:00
Vitor Pamplona
635279d59a Testing removing the gradient of quoted posts. 2023-05-05 18:49:32 -04:00
Vitor Pamplona
0c673e91c6 BugFix for displaying other people's list after loading them from their profile 2023-05-05 18:49:13 -04:00
Vitor Pamplona
3519e20a98 BugFix for the empty selection 2023-05-05 18:48:37 -04:00
Vitor Pamplona
7167d57d22 v0.42.4 2023-05-05 13:49:41 -04:00
Vitor Pamplona
86adac95c9 Adds protections for calculating zap amounts without a private key 2023-05-05 13:49:18 -04:00
Vitor Pamplona
cffcc158f5 0.42.3 2023-05-05 13:29:20 -04:00
Vitor Pamplona
49a8778975 BugFix for NPE on null ZappedNote 2023-05-05 13:26:55 -04:00
Believethehype
dd62d2bae4 activated nostrfiles_dev for stories feed. 2023-05-05 19:25:31 +02:00
Believethehype
3c3b6c97c0 deactivate nostrfiles_dev selection for now (because of space) 2023-05-05 19:21:25 +02:00
Believethehype
dabeea43ea added hoster nostrfiles.dev 2023-05-05 18:59:24 +02:00
Vitor Pamplona
5b6b6f19e5 Reverting background refactoring due to color being stuck in recompositions. 2023-05-05 10:01:37 -04:00
Vitor Pamplona
28370dbc01 Merge pull request #395 from ZsZolee/main
Follow list Hungarian translation
2023-05-05 09:11:03 -04:00
greenart7c3
28521c1a60 add configuration screen when enabling tor in the drawer 2023-05-05 08:25:48 -03:00
greenart7c3
9c30547bd7 fix build 2023-05-05 06:34:14 -03:00
greenart7c3
b07fd253c7 Merge branch 'main' into main 2023-05-05 06:11:24 -03:00
Zoltan
ab841c6490 Follow list Hungarian translation 2023-05-05 10:38:53 +02:00
Vitor Pamplona
12ff434a07 v0.42.2 2023-05-04 22:35:08 -04:00
Vitor Pamplona
51432c8c50 Fixing the toByteArray call 2023-05-04 22:34:09 -04:00
Vitor Pamplona
483e7d673c Refactoring part of NoteCompose 2023-05-04 21:57:19 -04:00
Vitor Pamplona
3ce6ce9951 v0.42.1 2023-05-04 21:14:55 -04:00
Vitor Pamplona
84f51dc473 Removing animation from blurhash 2023-05-04 21:14:42 -04:00
Vitor Pamplona
5f4886ce52 Fixes migration of follow lists from old accounts 2023-05-04 21:10:09 -04:00
Vitor Pamplona
dea6f0ca21 v0.42.0 2023-05-04 20:10:01 -04:00
Vitor Pamplona
01bdc41286 Merge remote-tracking branch 'origin/HEAD' 2023-05-04 20:08:50 -04:00
Vitor Pamplona
8c2e89197e Adds support for Private and Public Follow Lists 2023-05-04 20:08:36 -04:00
Vitor Pamplona
712950c2a1 Merge pull request #393 from believethehype/hashtag_fixes
Update HashtagIcon.kt
2023-05-04 11:49:38 -04:00
Believethehype
95cc2ee589 Update HashtagIcon.kt 2023-05-04 13:29:00 +02:00
Vitor Pamplona
76283463b0 v0.41.0 2023-05-03 18:26:52 -04:00
Vitor Pamplona
5179a821b2 Adjustments to the image upload choice to make sure they fit in a usual screen. 2023-05-03 18:25:01 -04:00
Vitor Pamplona
974a8011f9 Merge remote-tracking branch 'origin/main' 2023-05-03 17:49:14 -04:00
Vitor Pamplona
771bb3fc27 Merge pull request #391 from believethehype/nostrbuild
Added example for Image upload on nostr.build
2023-05-03 17:48:50 -04:00
Vitor Pamplona
10bd63ca2f Merge pull request #392 from Bardesss/patch-4
Update strings.xml (NL)
2023-05-03 17:44:29 -04:00
Vitor Pamplona
1fad3d1c8a Testing the removal of the image proxy server. 2023-05-03 17:20:10 -04:00
Believethehype
67cc24c4d9 added nostr.build NIP94 2023-05-03 18:13:18 +02:00
Vitor Pamplona
4368b34569 Fixes tall images bleeding into other posts. 2023-05-03 10:21:52 -04:00
greenart7c3
264d6a40b3 increased the default timeout when using proxy 2023-05-03 09:00:14 -03:00
Bardesss
65540c8a95 Update strings.xml (NL)
Updated translations
2023-05-03 08:13:35 +00:00
Bardesss
b801aa6bd1 Update strings.xml (NL)
Updated translation
2023-05-03 08:08:15 +00:00
Believethehype
7224ad4b05 image upload works to nostr.build 2023-05-03 00:48:01 +02:00
Believethehype
b043e2da2c Merge remote-tracking branch 'upstream/main' into nostrbuild 2023-05-03 00:27:39 +02:00
Vitor Pamplona
f8a60203ec Adds NFC support for NIP19 URIs 2023-05-02 17:59:12 -04:00
Vitor Pamplona
7f18bc3a88 0.40.6 2023-05-02 17:11:56 -04:00
Vitor Pamplona
31740d8855 Adding Post Not found on NIP95 images that are not found in any relay. 2023-05-02 17:06:32 -04:00
Vitor Pamplona
8641e0fec5 Only showing the loading url after 200 ms to avoid blinking the text. 2023-05-02 17:04:06 -04:00
Vitor Pamplona
c8f1e13448 Formatting 2023-05-02 16:47:02 -04:00
Vitor Pamplona
580cf76577 BugFix on calculating the Zap amount. 2023-05-02 16:46:52 -04:00
Vitor Pamplona
61549ebfd3 BugFix for video saving tool 2023-05-02 16:03:04 -04:00
Vitor Pamplona
797d1bb2b4 Don't try to draw local image if the file does not exist 2023-05-02 15:53:44 -04:00
Vitor Pamplona
99c16879c1 Uses resizeMode according to context. 2023-05-02 15:53:15 -04:00
Vitor Pamplona
0bf80e88ce Avoids videos getting out of the current page and leaking into the next page. 2023-05-02 15:45:32 -04:00
Vitor Pamplona
a71679ef43 Removing the exposure of the URL from the initial image and video loads. 2023-05-02 15:44:59 -04:00
Vitor Pamplona
4bbb34e8c9 v0.40.5 2023-05-02 14:38:18 -04:00
Vitor Pamplona
0e1420a626 Adjust volume based on changing global mute variable. 2023-05-02 14:38:09 -04:00
Vitor Pamplona
79782ee8e7 Fixes the volume adjusting interfering with other apps. 2023-05-02 14:32:49 -04:00
Vitor Pamplona
81b70780a6 v0.40.4 2023-05-02 12:42:38 -04:00
Vitor Pamplona
277fc5971e Moving Home feed to start first when loading the app. 2023-05-02 12:31:03 -04:00
Vitor Pamplona
850bc15f4f More visible buttons in the Video feed. 2023-05-02 12:20:05 -04:00
Vitor Pamplona
6d2db65c12 Basic Mp3 Support 2023-05-02 11:48:38 -04:00
Vitor Pamplona
89ce6dbab2 Forces search types to be used by the search components and reorganizes search 2023-05-02 11:39:29 -04:00
Vitor Pamplona
80bfbf68b4 Sending dimensions to Image files. 2023-05-02 10:02:45 -04:00
Vitor Pamplona
4464247ddb NVK is beautiful. 2023-05-02 08:20:45 -04:00
Vitor Pamplona
486d43dc1b 0.40.3 2023-05-01 20:44:37 -04:00
Vitor Pamplona
6731ca59cd BugFix for mixing NIP94 and NIP95 selections 2023-05-01 20:43:31 -04:00
Vitor Pamplona
7d620cd89a BugFix for now showing the error message when the phone can't open the file 2023-05-01 20:43:14 -04:00
Vitor Pamplona
82a2586eab Adds language to warn that NIP94 and 95 are new and other clients might not see it. 2023-05-01 20:42:48 -04:00
Vitor Pamplona
5a3f912b73 Merge pull request #389 from vivganes/translation-ta
Add tamil translations
2023-05-01 20:06:56 -04:00
Vitor Pamplona
bc54134e65 0.40.2 2023-05-01 18:54:51 -04:00
Vitor Pamplona
0f080219b2 Starts on mute 2023-05-01 18:54:06 -04:00
Vitor Pamplona
ff54629601 Autoplay controls 2023-05-01 18:53:07 -04:00
Believethehype
f889ff2547 added nostrbuild, can only upload very small files right now 2023-05-02 00:23:59 +02:00
Vitor Pamplona
8f257a7e2d - Displays Video Uploading Progress
- Keeps position in the Video feed between screen
- Animates to the top when posting
- Adds NIP-95 in-disk caching
- Fixes the Crossfade for BlurHash on NIP94 and NIP95 events
2023-05-01 16:42:58 -04:00
Vitor Pamplona
9015089593 Correctly loading NIP95 events from relays. 2023-05-01 16:39:07 -04:00
Vitor Pamplona
2b87930283 Removing the need to prune NIP95 events. 2023-05-01 16:38:35 -04:00
vivganes
15d94ca9c4 Add tamil translations 2023-05-01 21:15:55 +05:30
Vitor Pamplona
44af7f958c Merge pull request #388 from vitorpamplona/zap_payments
Zap payments without zap services on the receiving end.
2023-05-01 11:22:01 -04:00
Vitor Pamplona
2b2028853e Finishes the payment database 2023-05-01 11:12:46 -04:00
Vitor Pamplona
66dcc03351 Merge remote-tracking branch 'origin/HEAD' into zap_payments 2023-05-01 08:55:52 -04:00
Vitor Pamplona
22ba150c4e v0.40.1 2023-04-30 21:35:53 -04:00
Vitor Pamplona
63cc00976f Fixes https://github.com/vitorpamplona/amethyst/issues/387 2023-04-30 21:34:19 -04:00
Vitor Pamplona
cc9d4c237a Fixes Bug with the Nostr's Report use of double asterisk 2023-04-30 21:31:37 -04:00
Vitor Pamplona
446135ea2a Adding Zap Payments to the calculation of amounts and zapped by 2023-04-30 21:30:30 -04:00
Vitor Pamplona
a38bbd7168 Adding hex index for the Video Feed 2023-04-30 17:00:47 -04:00
Vitor Pamplona
635ed1afc1 v0.40.0 2023-04-29 19:45:27 -04:00
Vitor Pamplona
130ad40a3a Merge pull request #385 from vitorpamplona/tiktok
Tiktok
2023-04-29 19:44:36 -04:00
Vitor Pamplona
be5e58111e Merge remote-tracking branch 'origin/HEAD' 2023-04-29 19:31:12 -04:00
Vitor Pamplona
8b012884b1 0.39.0 2023-04-29 19:31:00 -04:00
Vitor Pamplona
4949a5c459 Merge pull request #384 from ZsZolee/main
Upload server Hungarian translation
2023-04-29 19:30:06 -04:00
Vitor Pamplona
ab330fea96 v0.39.0 2023-04-29 19:29:42 -04:00
Vitor Pamplona
8209c45303 New Video Feed based on NIP-94 and 95 2023-04-29 19:28:46 -04:00
Zoltan
206935289e Upload server Hungarian translation 2023-04-29 10:45:16 +02:00
Vitor Pamplona
571ff0e72b v0.38.0 2023-04-28 18:42:44 -04:00
Vitor Pamplona
940914adf5 Merge remote-tracking branch 'origin/HEAD' 2023-04-28 18:40:32 -04:00
Vitor Pamplona
1496f012a5 Adds new Highlight event kind from https://highlighter.com/ 2023-04-28 18:40:12 -04:00
greenart7c3
20a65a0878 check if orbot is installed 2023-04-28 11:21:06 -03:00
greenart7c3
6285bfaae7 updated string.xml 2023-04-28 08:15:04 -03:00
Vitor Pamplona
2a552bea4d Update PRIVACY.md
Clarifying that the PRIVACY policy covers the distribution usage and not the source code usage.
2023-04-27 19:30:20 -04:00
Vitor Pamplona
d1dc06a467 v0.37.4 2023-04-27 18:29:04 -04:00
Vitor Pamplona
f173f3194b Adjusts size of the Zap Forward text. 2023-04-27 18:27:56 -04:00
Vitor Pamplona
8ca63f32f4 Avoids flickering in the Global Feed 2023-04-27 18:20:04 -04:00
Vitor Pamplona
6b3e10ed28 Version 0.37.3 2023-04-27 17:37:23 -04:00
Vitor Pamplona
1ae785061e Reverting compose to 1.4.2 2023-04-27 17:35:53 -04:00
Vitor Pamplona
ddba4ac510 Add logs for fail to decrypt zap 2023-04-27 17:35:36 -04:00
Vitor Pamplona
0fe6bd6c83 Bugfix for clickable url 2023-04-27 17:13:56 -04:00
Vitor Pamplona
925289ae64 Reducing the amount of filters to send to relays to 10 2023-04-27 17:13:45 -04:00
Vitor Pamplona
f6f82a16f5 v0.37.2 2023-04-27 16:23:38 -04:00
Vitor Pamplona
54511dcc69 Updating dependencies 2023-04-27 16:23:30 -04:00
Vitor Pamplona
2f457d135f Improving translations 2023-04-27 15:54:26 -04:00
Vitor Pamplona
362082d608 Correcly copy pasting NIP94 and NIP95 addresses. 2023-04-27 15:40:28 -04:00
Vitor Pamplona
78cfa98456 Merge pull request #381 from vivganes/non-english-hashtags
Add support for non-english hashtags too
2023-04-27 15:28:38 -04:00
Vitor Pamplona
5e37a97d1e Merge branch 'main' into non-english-hashtags 2023-04-27 15:28:08 -04:00
Vitor Pamplona
561189144e v0.37.1 2023-04-27 15:06:04 -04:00
Vitor Pamplona
285a61e38f Enabling non NIP94 uploads for the PlayStore 2023-04-27 15:01:58 -04:00
Vitor Pamplona
2ca877b3c3 Zap Forwarding 2023-04-27 14:43:28 -04:00
vivganes
f105188738 Add support for non-english hashtags too 2023-04-27 21:13:25 +05:30
Vitor Pamplona
0a11de1892 Adds Zap Address preference to the zap tag https://github.com/nostr-protocol/nips/pull/402/files 2023-04-27 09:27:29 -04:00
Vitor Pamplona
1596a7b559 Pruning file storage events when pausing the app. 2023-04-27 09:26:26 -04:00
Vitor Pamplona
d4a3ee2651 Adding nip95 to readme 2023-04-26 19:04:01 -04:00
Vitor Pamplona
c60ad472e8 v0.37.0 2023-04-26 18:05:19 -04:00
Vitor Pamplona
04e4c4692f disabling logs 2023-04-26 18:05:02 -04:00
Vitor Pamplona
ab747f5e93 Activating NIP-95 support 2023-04-26 18:04:38 -04:00
Vitor Pamplona
e370e75ba4 Offers NostrImg as a choice of image server 2023-04-26 16:36:52 -04:00
Vitor Pamplona
a669e37774 Adds server selection support 2023-04-26 14:23:06 -04:00
Vitor Pamplona
86fe9b4a65 Adds support for NIP-95 Images 2023-04-26 14:22:49 -04:00
Vitor Pamplona
c6a0b0950a Fixes AUTH NIP42 bug. Amethyst was sending the event as a regular EVENT and not an AUTH command 2023-04-26 13:51:22 -04:00
greenart7c3
77a1b2ba89 show a dialog when disabling tor 2023-04-26 14:41:36 -03:00
Vitor Pamplona
f03cd04566 Merge pull request #380 from ZsZolee/main
Zap Type Hungarian translation
2023-04-26 13:02:01 -04:00
greenart7c3
3217d5095b created initProxy 2023-04-26 11:54:04 -03:00
greenart7c3
5d9d7ba16e created getProxy function 2023-04-26 10:19:30 -03:00
greenart7c3
c648f54b51 created HttpClient class 2023-04-26 10:03:51 -03:00
greenart7c3
4f8d33d850 fix build 2023-04-26 08:51:58 -03:00
greenart7c3
9f3d6a75e6 add proxy to all http clients 2023-04-26 08:42:49 -03:00
Zoltan
3175d40482 Zap Type Hungarian translation 2023-04-26 11:08:13 +02:00
Vitor Pamplona
24d722cdb7 v0.36.0 2023-04-25 21:19:32 -04:00
Vitor Pamplona
14bc1fb7d4 Support for NIP-42 2023-04-25 21:18:33 -04:00
Vitor Pamplona
90955515d0 Updating readme with the new features. 2023-04-25 18:26:18 -04:00
Vitor Pamplona
a22ec35a26 v0.35.1 2023-04-25 18:16:51 -04:00
Vitor Pamplona
f38f5737b0 Opens image dialog in the page of the current selection. 2023-04-25 18:11:08 -04:00
Vitor Pamplona
e958515dad Improves configuration of Zap types (double click and click and hold) 2023-04-25 18:06:00 -04:00
Vitor Pamplona
add6b06446 Marks all Zaps to be private by default. 2023-04-25 17:13:07 -04:00
Vitor Pamplona
d070712f2b Activates gif preview in Notifications 2023-04-25 17:12:48 -04:00
Vitor Pamplona
b9f58b3701 Correctly updating the screen after zaps have been accounted for 2023-04-25 17:02:44 -04:00
Vitor Pamplona
91f3f036ae Making poll votes work with Private Zaps. 2023-04-25 16:35:42 -04:00
Vitor Pamplona
4ef90e26cf Merge pull request #377 from ZsZolee/main
New Hungarian translation
2023-04-25 14:27:13 -04:00
Vitor Pamplona
f7182d6393 Preview for quote noting. 2023-04-25 13:37:39 -04:00
Vitor Pamplona
99861facc9 Improving the performance and UI of Zap notification messages. 2023-04-25 09:21:12 -04:00
Vitor Pamplona
a5728daeb0 Adds FileHeader loading to a thread. 2023-04-25 08:20:51 -04:00
Zoltan
a4ac2eeb89 New Hungarian translation 2023-04-25 12:07:01 +02:00
Vitor Pamplona
a5a320b889 Merge pull request #376 from kiwiidb/main
fix bug in nip47 scheme check
2023-04-25 05:08:13 -04:00
kiwiidb
8f927c29e3 fix bug in nip47 scheme check 2023-04-25 10:15:07 +02:00
Vitor Pamplona
a739b27d14 v0.35.0 2023-04-24 21:04:01 -04:00
Vitor Pamplona
2f2ca97ec1 Updating dependencies. 2023-04-24 21:03:01 -04:00
Vitor Pamplona
114186ce06 Merge pull request #373 from vitorpamplona/new_wallet_connect
New wallet connect API
2023-04-24 18:55:40 -04:00
Vitor Pamplona
8187a7f5a9 removing debugging log 2023-04-24 18:54:19 -04:00
Vitor Pamplona
f8319c720a Support for the new JSON-based Wallet Connect API 2023-04-24 18:51:57 -04:00
Vitor Pamplona
3d93fe3d18 Merge remote-tracking branch 'origin/HEAD' into new_wallet_connect 2023-04-24 18:02:24 -04:00
Vitor Pamplona
b4373ae1ed Deprecating #[]s tags 2023-04-24 17:58:35 -04:00
greenart7c3
ad420da6de added tor icon 2023-04-24 16:37:00 -03:00
Vitor Pamplona
a37effd42d Merge pull request #372 from believethehype/privatezaps
Update for Custom Zaps
2023-04-24 10:25:47 -04:00
Vitor Pamplona
655cd20a01 Adds support for video and image descriptions for accessibility use cases 2023-04-24 10:21:01 -04:00
greenart7c3
a354fe928b Merge branch 'main' of github.com:greenart7c3/amethyst 2023-04-24 08:16:55 -03:00
Believethehype
a13dbf2bda decode private zaps and show in notifications + show zap messages 2023-04-23 22:48:53 +02:00
Vitor Pamplona
f1affc2dbb Formatting 2023-04-23 16:32:50 -04:00
Vitor Pamplona
cbc0f95498 Show previews of most notes, including NIP94 - images, on new posts. 2023-04-23 16:32:44 -04:00
Vitor Pamplona
b3f8b06fea formatting improvements 2023-04-23 16:10:20 -04:00
Vitor Pamplona
ed4c19ac79 Adjusts the Wallet Connect API to the new RPC design 2023-04-23 16:10:09 -04:00
Believethehype
37bd7e34a0 remember default ZapType
Based on last selection in ZapCustomDialog, Reaction Row Zaps will use the same zap type

Currently Zaps in Polls are always public.
2023-04-23 18:53:38 +02:00
Vitor Pamplona
c1d05f8b2f Adds some margin for reply quotes 2023-04-22 15:14:00 -04:00
Vitor Pamplona
9c715dd381 Fixing the position of the image loading animation 2023-04-22 15:02:33 -04:00
Vitor Pamplona
a720a636d3 Merge pull request #348 from believethehype/private_zaps
Send Private Zaps
2023-04-22 14:52:06 -04:00
Vitor Pamplona
5322d6e900 Merge pull request #367 from jltdhome/main
Added Adaptive Icon
2023-04-22 14:50:42 -04:00
Vitor Pamplona
9d849f12c0 Merge pull request #368 from believethehype/hashtags_release
New HashTag Icons #footstr #grownostr
2023-04-22 14:49:17 -04:00
Believethehype
18dc5354bd removed unused graphics 2023-04-22 20:13:23 +02:00
Believethehype
a013f1f1cc added new hashtag icons 2023-04-22 20:11:05 +02:00
Believethehype
e1d236d9cf Merge remote-tracking branch 'upstream/main' into hashtags_release 2023-04-22 20:04:04 +02:00
Josh
12a2a21c0e Added Adaptive Icon
Take advantage of themed icons in Android 13+
2023-04-21 21:06:19 -07:00
Believethehype
9ae13a9f97 Update LnZapRequestEvent.kt 2023-04-22 05:54:43 +02:00
Believethehype
bd64a7de63 clean up 2023-04-22 05:50:38 +02:00
Believethehype
12a3cd5113 Update LocalCache.kt 2023-04-22 05:37:52 +02:00
Believethehype
1d23e87bd9 refactoring 2023-04-22 05:32:48 +02:00
Believethehype
b23bea8971 Merge remote-tracking branch 'upstream/main' into private_zaps 2023-04-22 05:06:59 +02:00
Vitor Pamplona
830ade9ad6 v0.34.1 2023-04-21 18:22:11 -04:00
Vitor Pamplona
ed07248f7d Correctly calculates the total of Zaps in a poll 2023-04-21 18:20:26 -04:00
Vitor Pamplona
9aa5993748 Removes unnecessary logs 2023-04-21 18:20:15 -04:00
Vitor Pamplona
e7909beaab Remove spaces after bench and image previews 2023-04-21 18:20:00 -04:00
Vitor Pamplona
79e3450a46 v0.34.0 2023-04-21 17:07:07 -04:00
Vitor Pamplona
783204b57f Support for NIP-94 2023-04-21 17:01:42 -04:00
Vitor Pamplona
1adb2b2caa Fix crashing when changing users. 2023-04-21 11:00:06 -04:00
Vitor Pamplona
3f579793ca v0.33.2 2023-04-21 10:21:50 -04:00
Vitor Pamplona
8040608e14 Fixes: https://github.com/vitorpamplona/amethyst/issues/359 2023-04-21 10:21:24 -04:00
Believethehype
2d202e2244 resolving conflicts with latest main branch. 2023-04-21 15:48:04 +02:00
Vitor Pamplona
2e66d278ad Merge pull request #362 from vivganes/patch-361
Show clickable phone number and nostr:npub reference in markdown notes
2023-04-21 08:48:50 -04:00
Vitor Pamplona
1bfbf3643d Merge branch 'main' into patch-361 2023-04-21 08:48:41 -04:00
Vitor Pamplona
5990bf0213 Merge pull request #360 from Bardesss/patch-3
Update strings.xml (NL)
2023-04-21 08:44:33 -04:00
vivganes
5e3db99675 fix #361 2023-04-21 17:20:50 +05:30
Bardesss
a291741c37 Update strings.xml 2023-04-21 09:06:54 +00:00
Bardesss
94ee80d8ea Update strings.xml 2023-04-21 08:59:36 +00:00
Bardesss
f7de66c6a5 Update strings.xml (NL)
Added missing strings.
2023-04-21 07:34:42 +00:00
Vitor Pamplona
48d890b354 v0.33.1 2023-04-20 17:24:33 -04:00
Vitor Pamplona
c7a9bd1226 Using FlowRow to display relay icons. 2023-04-20 17:19:34 -04:00
Vitor Pamplona
c40c7bc62b Removing LazyGrid from reactions Row due to being slower than the Row itself. 2023-04-20 17:19:20 -04:00
Vitor Pamplona
cca7a10322 Caching images that cause errors and avoid calling them again. 2023-04-20 17:18:53 -04:00
Vitor Pamplona
fe6ef1df0a Migration to Gradle 8.0 2023-04-20 17:18:20 -04:00
greenart7c3
cf8747b19f add a checkbox to use tor in the login screen, set the proxy in the account model construction 2023-04-20 15:30:04 -03:00
Vitor Pamplona
3e27567532 Moves home and Notification bubbles to the adaptive feed. 2023-04-20 14:09:26 -04:00
Vitor Pamplona
0a15e079dc Compares room messages by reference. 2023-04-20 14:08:58 -04:00
Vitor Pamplona
48f4fc4a0f v0.33.0 2023-04-20 12:11:58 -04:00
Vitor Pamplona
d9396ce453 Migrating to the default Pager from Accompanist's library. 2023-04-20 10:55:00 -04:00
Vitor Pamplona
817c5172b6 Clearing some warnings up 2023-04-20 10:54:35 -04:00
Vitor Pamplona
cfdb20892e Updating dependencies 2023-04-20 10:53:09 -04:00
Vitor Pamplona
75922f93bc Moving ReactionsRow into a Lazy Grid 2023-04-20 10:09:28 -04:00
Vitor Pamplona
42801e3eca Using the URL Detector (which is faster) to catch urls in the text 2023-04-20 10:08:32 -04:00
Vitor Pamplona
64321dbe7c No need to sort. It will be resorted later. 2023-04-20 10:07:34 -04:00
Vitor Pamplona
b11b52b60c typo 2023-04-20 10:07:10 -04:00
Vitor Pamplona
00e5470642 Passing the size of the robohash rendering to the request in the hopes to improve performance. 2023-04-20 10:07:03 -04:00
Vitor Pamplona
7ee3b8a196 Improves performance of video loading and playback. 2023-04-20 10:06:12 -04:00
greenart7c3
2ae54b544f wip: add tor socks support 2023-04-20 08:21:11 -03:00
Vitor Pamplona
585bb57f64 Merge pull request #356 from vitorpamplona/additive_feed_test
Additive feeds (No more list reloadings in the main screens)
2023-04-19 16:49:37 -04:00
Vitor Pamplona
6981fe8f8a Adapting interfaces for the additive filter. 2023-04-19 16:35:26 -04:00
Vitor Pamplona
abdad7fbea solve recursive method call 2023-04-19 15:57:27 -04:00
Vitor Pamplona
57b35398be Activates updates 2023-04-19 15:57:17 -04:00
Vitor Pamplona
47398e6e86 Starts with an empty set as database 2023-04-19 15:56:58 -04:00
Vitor Pamplona
e775ae9ada Adding the new parameter. 2023-04-19 13:54:00 -04:00
Vitor Pamplona
6a9a321e2b Fixing format 2023-04-19 13:00:52 -04:00
Vitor Pamplona
0aee4407db Merge remote-tracking branch 'origin/HEAD' into additive_feed_test
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
2023-04-19 13:00:45 -04:00
Vitor Pamplona
a816fa561a Declares the use of the camera as a feature. 2023-04-19 11:34:38 -04:00
Vitor Pamplona
dc745c3cdb disables errors due to missing translations 2023-04-19 11:33:03 -04:00
Vitor Pamplona
0b30c83143 Moves coffee.xml to the right directory. 2023-04-19 11:32:52 -04:00
Vitor Pamplona
dc8804933d moving source code compatibility to java 17 2023-04-19 11:27:20 -04:00
Vitor Pamplona
b28f0bff74 Updating build scripts to java 17 2023-04-19 11:24:49 -04:00
Vitor Pamplona
e96e3c0760 Merge pull request #353 from ryogrid/quote_problem
fix bug: quote repost is tied up to a post which is replyed a little while ago
2023-04-19 10:38:40 -04:00
Vitor Pamplona
b7ea167420 Merge branch 'main' into quote_problem 2023-04-19 10:38:20 -04:00
Vitor Pamplona
744a55f497 Caching channel info JSON parse in NoteCompose. 2023-04-19 10:34:10 -04:00
Vitor Pamplona
b55f999efb Slight performance improvements on tag search. 2023-04-19 10:33:52 -04:00
Vitor Pamplona
4e09d9e54a Performance Improvements for Zaps in Polls. 2023-04-19 10:33:02 -04:00
Vitor Pamplona
5afa333fab Migration to Android Studio 2022.2.1 and Gradle 8 2023-04-19 09:58:11 -04:00
Ryo Kanbayashi
c0946f5c0e fix bug: quote repost is tied up to a post which is replyed a little while ago. (2) 2023-04-19 22:50:32 +09:00
Vitor Pamplona
3ece4fcf8b Merge pull request #352 from naderbal/drawer_back
Intercept back press when drawer is opened to close drawer
2023-04-18 17:10:15 -04:00
Vitor Pamplona
b1193c5d11 Merge pull request #344 from ZsZolee/main
Added new Hungarian translations
2023-04-18 17:09:43 -04:00
Vitor Pamplona
12b0afd9c6 Display note IDHex when measuring time. 2023-04-18 08:46:12 -04:00
Vitor Pamplona
df45d68b74 Caches user profile in Account. 2023-04-18 08:45:50 -04:00
Vitor Pamplona
11bf702e02 Moves DropDown state to the IO thread. 2023-04-18 08:45:34 -04:00
Vitor Pamplona
47e2683aa3 Moves firstIsTaggedHash search to the IO thread. 2023-04-18 08:45:02 -04:00
Vitor Pamplona
e4fc00669e Compares users by reference. 2023-04-18 08:44:32 -04:00
Vitor Pamplona
f64db1271b Uses createdAt instead of the card object, which can change randomly, to determine when to call LaunchedEffects. 2023-04-18 08:44:11 -04:00
Vitor Pamplona
35647a8361 Fixes DM link in Message Notifications 2023-04-18 08:42:58 -04:00
Vitor Pamplona
a2c8658972 Only calculates nip05 after identifying if it is correct. 2023-04-18 08:42:32 -04:00
Vitor Pamplona
38e18cf0a7 Simplifies comparison for Channels via Hex code instead of objects 2023-04-18 08:41:59 -04:00
Vitor Pamplona
78c9974036 Removes invalidateFilters Logging 2023-04-18 08:41:29 -04:00
Believethehype
7a60a6804a Update LnZapEventInterface.kt 2023-04-17 20:14:17 +02:00
Believethehype
5e6e2835ca Receiving and decoding private Zaps
Private zaps get decoded correctly.

TODO: Update events or at least notifications in Damus to replace anon sender with the one from decrypted message. Bonus: Show Content of the Zap Message
2023-04-17 20:10:36 +02:00
Ryo Kanbayashi
180083147d removed needless added newline. 2023-04-16 22:26:55 +09:00
Ryo Kanbayashi
50b0b5af37 fix bug: quote repost is tied up to a post which is replyed a little while ago. 2023-04-16 22:12:54 +09:00
nalbaltaji
9d27b8d47e Intercept back press when drawer is opened to close drawer 2023-04-15 18:59:44 +02:00
Believethehype
fbb4dc5e59 additional hashtags 2023-04-13 15:35:37 +02:00
Believethehype
a2296f2365 Update grownostr.xml 2023-04-13 14:38:20 +02:00
Believethehype
58ff2e6157 Update HashtagIcon.kt 2023-04-13 00:19:20 +02:00
Believethehype
ff490a2b14 Send Private Zaps 2023-04-13 00:15:36 +02:00
Believethehype
067d694036 some more community hashtags 2023-04-12 20:49:32 +02:00
Zoltan
401d807d29 Added new Hungarian translations 2023-04-11 10:26:19 +02:00
Vitor Pamplona
eab202f721 Merge pull request #340 from toadlyBroodle/main
#zappoll hashtag
2023-04-10 10:07:13 -04:00
Vitor Pamplona
f726c656b5 Merge pull request #343 from believethehype/zaptypes
introduce zap types, implemented non-zaps in custom zap dialog
2023-04-10 10:03:35 -04:00
Believethehype
37dab1c8b6 Update LnZapEvent.kt 2023-04-10 13:54:29 +02:00
Believethehype
9a5d8522fb added anonymous zaps 2023-04-10 13:25:09 +02:00
Believethehype
5abbd07722 introduce zap types, implemented non-zaps in custom zap dialog
Non-Zaps allow to tip the author of a note without using the zap mechanism (no record on the note)
2023-04-09 23:43:36 +02:00
toadlyBroodle
13579f9473 add #zappoll to new poll messages 2023-04-09 11:26:02 +09:00
Vitor Pamplona
cea9cdcd34 v0.32.3 2023-04-07 18:33:36 -04:00
Vitor Pamplona
ed22155940 Simplifying tag structure 2023-04-07 18:30:34 -04:00
Vitor Pamplona
43497f2ce0 fixes format 2023-04-07 18:26:19 -04:00
Vitor Pamplona
80e58f72f8 Fixes nprofile1 tags 2023-04-07 18:26:13 -04:00
Vitor Pamplona
234751a4b3 Avoids loading markdown style when it won't be used. 2023-04-07 18:11:50 -04:00
Vitor Pamplona
ef567110d0 Moving processing flags to Launched Effect. 2023-04-07 18:02:54 -04:00
Vitor Pamplona
42b89f438b Only calculates follows when needed. 2023-04-07 17:44:40 -04:00
Vitor Pamplona
b01df2b38e Fixes MultiSet Compose Routes 2023-04-07 17:41:00 -04:00
Vitor Pamplona
b89efb547e Correctly points Users to the right DM when clicking in the notification. 2023-04-07 17:38:25 -04:00
Vitor Pamplona
e6554a5b4a Moves tags calculation to Launched Effect 2023-04-07 17:38:02 -04:00
Vitor Pamplona
c58b13e2f7 Simplifies modifiers 2023-04-07 17:37:25 -04:00
Vitor Pamplona
241372ce61 Adds DMs to the id search filter 2023-04-07 17:37:07 -04:00
Vitor Pamplona
49ed594f53 Caches logged user in Note compose 2023-04-07 17:21:21 -04:00
Vitor Pamplona
03b76357ff Migrates poll and invoice option availability to a mutable state 2023-04-07 17:20:18 -04:00
Vitor Pamplona
97490bb6ef Using bitcoin icon instead of dollar. 2023-04-07 17:19:47 -04:00
Vitor Pamplona
dbf5267c5c Moves LnInvoice, Tags, BechLinks and Previews to LaunchedEffect 2023-04-07 16:58:25 -04:00
Vitor Pamplona
e1ce638d7f Compares by reference to simplify code 2023-04-07 16:56:20 -04:00
Vitor Pamplona
88b80a0da1 Typo 2023-04-07 16:56:08 -04:00
Vitor Pamplona
d6b571590a Sends simpler object to help compose's stability 2023-04-07 16:55:46 -04:00
Vitor Pamplona
83f3af340f Uses Kotlin's rounding function instead of java 2023-04-07 16:55:15 -04:00
Vitor Pamplona
e9cf9b21a8 Merge pull request #339 from toadlyBroodle/main
Prevent author vote manipulation
2023-04-07 14:43:00 -04:00
Vitor Pamplona
b1645590fe Merge pull request #336 from wklkejw239/patch-1
add contributors in README.md
2023-04-07 14:41:48 -04:00
Vitor Pamplona
a5da4ea53d Streamlines Global filter for performance 2023-04-07 14:26:23 -04:00
Vitor Pamplona
3a082d131f Streamlines Notification filter for performance 2023-04-07 14:26:07 -04:00
Vitor Pamplona
28d501ed02 Uses more direct functions to get and compare tags as opposed to the fail safe options. 2023-04-07 14:25:38 -04:00
Vitor Pamplona
a04fd4a9f2 Matches parameter names to the Event class. 2023-04-07 14:25:03 -04:00
Vitor Pamplona
2424dc3961 Brings the findCitedUsers function to BaseTextNote Event with cache. 2023-04-07 14:24:43 -04:00
Vitor Pamplona
58f1515387 Displays feed performance measurement before clipping to 1000 objects 2023-04-07 14:24:00 -04:00
Vitor Pamplona
9ee3da3910 Uses the inHidden in Hex 2023-04-07 14:23:34 -04:00
Vitor Pamplona
cc44b4dd55 Removes reposts from conversations 2023-04-07 14:23:19 -04:00
Vitor Pamplona
b8e4ccfc28 Lint mentions cannot be null. 2023-04-07 14:22:48 -04:00
Vitor Pamplona
0fc6757d4a Adds inHidden check for hex keys 2023-04-07 14:22:16 -04:00
toadlyBroodle
ee94cc6bf6 don't allow poll authors to vote in their own polls 2023-04-07 08:41:34 +09:00
Vitor Pamplona
f224fe6b40 v0.32.2 2023-04-06 12:23:35 -04:00
Vitor Pamplona
403a1723de Previews Notes that are not a citation using #[n] 2023-04-06 11:59:21 -04:00
Vitor Pamplona
6cf1960df4 Adds a New Message Tagger to Chats (tags Notes and Users by default) and Private Message (doesn't tag Users and Notes) 2023-04-06 11:58:49 -04:00
Vitor Pamplona
8676752f19 Fixes Channel's reply to filtration (removes citations and the channel id from replies) 2023-04-06 11:57:28 -04:00
Vitor Pamplona
7012d949c2 Fixes formatting 2023-04-06 11:56:23 -04:00
Vitor Pamplona
8344274011 Adds Invoice Creation to New Posts. 2023-04-06 11:54:28 -04:00
vincivermeer
2bda50ad4a add contributors in README.md
Hello everyone!

I added the contributors in the readme.md file in the repository: vitorpamplona/amethyst. I would like to contribute to this pull-request and I hope you all accept it.

what do you all think of this idea?
2023-04-06 00:11:49 -03:00
Vitor Pamplona
b0953310c2 Making the poll click animation wider. 2023-04-05 11:10:14 -04:00
Vitor Pamplona
65910295db Adds notification for Zaps that don't have a Note attached to them. 2023-04-05 08:46:25 -04:00
Vitor Pamplona
7a957bd6ec Initializing indexes with large sizes from the start. 2023-04-05 08:45:58 -04:00
Vitor Pamplona
292ce779a1 Lazy initialization of the json mapper (300ms) 2023-04-05 08:45:33 -04:00
Vitor Pamplona
924b21cdfc Reducing the use of Slow disk access of Local Preferences. 2023-04-05 08:44:48 -04:00
Vitor Pamplona
7b539e63bd Merge pull request #334 from Poussinou/patch-1
Update README.md
2023-04-05 08:21:06 -04:00
Poussinou
d37fef94fe Update README.md 2023-04-05 21:24:47 +09:30
Vitor Pamplona
93a1853317 Merge pull request #332 from braydonf/readme
Update readme gradlew install commands
2023-04-04 19:48:16 -04:00
Braydon
9944f98bde Update readme gradlew install commands 2023-04-04 16:24:44 -07:00
Vitor Pamplona
05138232f4 Fixes missing function 2023-04-04 16:12:20 -04:00
Vitor Pamplona
83d7085375 Hides Poll option when replying to channel or DMs. 2023-04-04 16:10:55 -04:00
Vitor Pamplona
a8a3915c8d Allowing replies of PMs from Notifications. 2023-04-04 16:09:54 -04:00
Vitor Pamplona
83bcf53583 v0.32.1 2023-04-04 14:11:05 -04:00
Vitor Pamplona
875d9544ec Fixes Poll Color for Light mode. 2023-04-04 13:59:02 -04:00
Vitor Pamplona
7ce2a0bbea Makes sure before and after voting the padding is the same. 2023-04-04 13:57:06 -04:00
Vitor Pamplona
a3b9ea6a42 Fixes format 2023-04-04 13:51:27 -04:00
Vitor Pamplona
ca2c811859 Fixes Voted option highlight. 2023-04-04 13:49:41 -04:00
Vitor Pamplona
b09bb0c3c5 Fixes margins when images are used in the poll 2023-04-04 13:39:12 -04:00
Vitor Pamplona
a68ea1abe5 Adds margin between the reply note and the text 2023-04-04 12:35:42 -04:00
Vitor Pamplona
02a9aaab60 Removes the 5dp space on the generic RichText Viewer 2023-04-04 12:29:25 -04:00
Vitor Pamplona
efd2ddba71 Adds payment animation to Polls 2023-04-04 12:25:32 -04:00
Vitor Pamplona
e10c5771d8 v0.32.0 2023-04-04 11:27:23 -04:00
Vitor Pamplona
b685b38565 Avoids triggering exceptions when the NIP19 parser receives a simple hex key 2023-04-04 11:21:36 -04:00
Vitor Pamplona
15822fee8e Fixes Translation bug not being active on google play edition 2023-04-04 11:20:57 -04:00
Vitor Pamplona
e96e218025 Adds relay param in spam filter for polls 2023-04-03 20:46:22 -04:00
Vitor Pamplona
a11263ce9e Correcting format 2023-04-03 20:42:05 -04:00
Vitor Pamplona
4417201582 Merge pull request #330 from vitorpamplona/pr/321
Moving new pool button to NewPost
2023-04-03 20:35:41 -04:00
Vitor Pamplona
6a85ab743d Moving new pool button to NewPost 2023-04-03 20:31:25 -04:00
Vitor Pamplona
4434b0e0e5 Merge pull request #321 from toadlyBroodle/main
Polls
2023-04-03 20:26:57 -04:00
Vitor Pamplona
7c8381624b Merge branch 'main' into main 2023-04-03 20:26:50 -04:00
Vitor Pamplona
e470531210 Adds Show QR option to Profile screen 2023-04-03 15:04:52 -04:00
Vitor Pamplona
182cf62a3b v0.31.4 2023-04-03 14:02:19 -04:00
Vitor Pamplona
3e666df896 Adds full nip19 parsing on key decoding capabilities. 2023-04-03 14:01:43 -04:00
Vitor Pamplona
20aca144fa Moves Login QR Button to a leading QR, not a trailing QR 2023-04-03 14:01:00 -04:00
Vitor Pamplona
25d9f7b529 Merge pull request #327 from believethehype/main
added message to LnZapEvent and interface
2023-04-03 13:07:48 -04:00
Vitor Pamplona
306a7b3041 Merge pull request #329 from greenart7c3/main
Add qrCode scanner to the login page
2023-04-03 13:07:08 -04:00
greenart7c3
bae1b6c6e6 Merge branch 'main' of github.com:greenart7c3/amethyst 2023-04-03 13:55:06 -03:00
greenart7c3
f2311f294e Merge branch 'vitorpamplona:main' into main 2023-04-03 13:54:48 -03:00
greenart7c3
ad2eabf386 add qrcode scanner to login page 2023-04-03 13:54:13 -03:00
greenart7c3
b51f551b4b fix formatting 2023-04-03 13:49:08 -03:00
Vitor Pamplona
af751d56ce Hunting a bug of missing zapRequests 2023-04-03 12:03:00 -04:00
Vitor Pamplona
f4d66d9cbf Speeding up RichText rendering parsers 2023-04-03 12:02:26 -04:00
Vitor Pamplona
37d839504c Avoiding crashes when zap description is blank 2023-04-03 11:53:02 -04:00
Believethehype
e8e696dcea added message to LnZapEvent and interface 2023-04-02 23:25:37 +02:00
Vitor Pamplona
4db0faeda4 Printing time on EOSETime class for logs 2023-04-02 11:54:40 -04:00
Vitor Pamplona
b8ca1a15fb Adds event kind to the NoteCompose log 2023-04-02 11:43:15 -04:00
Vitor Pamplona
407ee74a55 Faster processing of Contact List events 2023-04-02 11:42:38 -04:00
Vitor Pamplona
67bcc52b25 Faster user in follow list algo 2023-04-02 11:42:11 -04:00
Vitor Pamplona
9afc94c050 Display relay info in spam message logs 2023-04-02 11:32:38 -04:00
toadlyBroodle
9d9283e05f clean up imports,
fix translatable typo
2023-04-02 13:58:07 +09:00
toadlyBroodle
5692625922 show amount for atomic polls before zapping 2023-04-02 13:46:24 +09:00
toadlyBroodle
7e07573fa5 fix voting on closed polls bug 2023-04-01 10:23:45 +09:00
toadlyBroodle
1f08f33600 merge with recent changes to main 2023-04-01 09:54:13 +09:00
Vitor Pamplona
1ee3dc0ff3 v0.31.3 2023-03-31 18:36:58 -04:00
Vitor Pamplona
3626f24b5f Adds citations to the notification feed 2023-03-31 18:36:05 -04:00
Vitor Pamplona
4bced4dbea v0.31.2 2023-03-31 17:00:17 -04:00
Vitor Pamplona
737244fb60 Fixes "author_is_elon" bug 2023-03-31 16:59:23 -04:00
Vitor Pamplona
22adbddd88 v0.31.1 2023-03-31 15:41:07 -04:00
Vitor Pamplona
6c5fecc4d2 Loads the replies of the selected note. 2023-03-31 15:30:57 -04:00
Vitor Pamplona
97017e2bd5 v0.31.0 2023-03-31 14:12:59 -04:00
Vitor Pamplona
3145e381a0 Refactoring EOSEs to consider account switching in the app. 2023-03-31 13:50:24 -04:00
Vitor Pamplona
bd62736002 Uses per subscription EOSE to avoid downloading everything again when Resuming the app. 2023-03-31 12:26:13 -04:00
Vitor Pamplona
6884d5e8dd Fixes thread loading issue where the clicked note's children is not loaded. 2023-03-31 10:53:34 -04:00
Vitor Pamplona
27235b0f77 Changes the download folder to remove Amethyst's version. 2023-03-31 10:06:30 -04:00
toadlyBroodle
24dc4ba7a5 remove hacky fun for getting poll_option from ZapEvent 2023-03-31 13:33:42 +09:00
toadlyBroodle
a01a28de0e merge with recent changes to main 2023-03-31 10:50:37 +09:00
Vitor Pamplona
9fa3b1db34 Keeping most items visible when grouping notification cards. 2023-03-30 19:47:48 -04:00
Vitor Pamplona
3193a5e0ef Updating search as new notes come in. 2023-03-30 18:53:20 -04:00
Vitor Pamplona
3137750a58 Centers the loading icon for images. 2023-03-30 18:35:30 -04:00
Vitor Pamplona
20068a40d4 Activates more relays for chats. 2023-03-30 18:23:39 -04:00
Vitor Pamplona
ef5980e90b BugFix: Some events not loading. 2023-03-30 18:16:14 -04:00
Vitor Pamplona
04a3fe2f4c Groups Notifications on Double click in the navigation row. 2023-03-30 17:52:35 -04:00
Vitor Pamplona
33467c62fa Adds PoW Display to the MasterNote of a Thread 2023-03-30 17:46:01 -04:00
Vitor Pamplona
a3e94dbaef Dependency updates 2023-03-30 16:53:13 -04:00
Vitor Pamplona
b4e39f2b73 Full Wallet Connect onboarding with Alby. 2023-03-30 16:10:13 -04:00
Vitor Pamplona
b4e409db4d Saves the position of the Notification feed 2023-03-30 13:58:28 -04:00
Vitor Pamplona
eff07bfb1e Breaks notification cards in chunks of 50 notifications each. 2023-03-30 11:02:39 -04:00
Vitor Pamplona
7f305f498b Only display PoW status with 8 bits of zeros - 0x00.. 2023-03-30 09:10:27 -04:00
Vitor Pamplona
bfb7de896c Merge pull request #325 from ZsZolee/main
Custom Zaps Hungarian Translation
2023-03-30 08:41:06 -04:00
Zoltan
2c8c4bede7 Custom Zaps Hungarian Translation 2023-03-30 10:36:40 +02:00
Vitor Pamplona
b079b3ef79 0.30.2 2023-03-29 18:15:44 -04:00
Vitor Pamplona
a8c8ee1539 BugFix: Stick reply setup from Gigi. 2023-03-29 18:06:41 -04:00
Vitor Pamplona
8f14ccda86 BugFix Sticky notifications on account change 2023-03-29 18:06:19 -04:00
Vitor Pamplona
a66f001033 Adds Image Upload on Profile Edit pages. 2023-03-29 17:22:21 -04:00
Vitor Pamplona
ca79ceb1b9 Adjusts custom zap messages with double click. 2023-03-29 17:00:01 -04:00
Vitor Pamplona
0abac47ff2 More forgiving Wallet Connect interface (nsec or hexes are accepted, relay url is trimmed and cleaned up, if relay doesn't have a wss://, adds one) 2023-03-29 16:21:11 -04:00
Vitor Pamplona
5038588d70 Merge pull request #319 from believethehype/main
Custom Messages for Zaps
2023-03-29 16:05:26 -04:00
Vitor Pamplona
19dab9ef4d Fixes hungarian xml 2023-03-29 15:57:03 -04:00
Vitor Pamplona
374fe85ce9 Merge remote-tracking branch 'origin/HEAD' 2023-03-29 15:52:42 -04:00
Vitor Pamplona
c1120aede3 Merge pull request #324 from believethehype/wallet-connect
Making it clearer in which format the relay should be entered for wallet-connect
2023-03-29 15:52:08 -04:00
Vitor Pamplona
e2f93ec17e Merge pull request #323 from ZsZolee/main
Adding additional Hungarian Translation
2023-03-29 15:51:42 -04:00
Vitor Pamplona
78adb757da Merge pull request #322 from believethehype/profile_longnames
Fixed long usernames in profile
2023-03-29 15:51:21 -04:00
Vitor Pamplona
9a6f88b81b Turning LocalCache Listeners into an Additive Feed type. 2023-03-29 15:14:52 -04:00
Believethehype
3cd6bab7e9 Update UpdateZapAmountDialog.kt 2023-03-29 18:26:44 +02:00
believethehype
1918fe24fc Merge branch 'vitorpamplona:main' into main 2023-03-29 18:24:10 +02:00
Zoltan
5262368b61 Merge pull request #1 from ZsZolee/HUN-add
Adding additional Hungarian Translation
2023-03-29 10:58:42 +02:00
Zoltan
d6a8b70852 Adding additional Hungarian Translation 2023-03-29 10:57:23 +02:00
Believethehype
d61844436d Update ProfileScreen.kt 2023-03-29 10:52:31 +02:00
toadlyBroodle
32067fe800 merge branch polls into main 2023-03-29 16:00:15 +09:00
Vitor Pamplona
e41da35a02 Adds image url when previews can't be loaded. 2023-03-28 18:13:00 -04:00
Vitor Pamplona
b865d15adf Increases speed of the bottom navigation by avoiding recreating ViewModels 2023-03-28 17:11:38 -04:00
Believethehype
42e0cda2e2 don't reset dialog on close 2023-03-28 22:24:00 +02:00
Believethehype
ad70cfb8d7 updated ZapCustomDialog 2023-03-28 22:22:28 +02:00
believethehype
caaf554371 Merge branch 'vitorpamplona:main' into main 2023-03-28 21:15:22 +02:00
Vitor Pamplona
73cabc1b25 Fixing the position of the text in short messages with expandable content. 2023-03-28 11:34:20 -04:00
Vitor Pamplona
9590eecacd Turning Relay icon FlowRow into a LazyVerticalGrid to see if improves rendering speed. 2023-03-28 10:55:30 -04:00
Vitor Pamplona
8eb3a47844 Removing unnecessary logs from the Relay class 2023-03-28 09:01:47 -04:00
Vitor Pamplona
9ae860a66a Moving byte count to count all messages from relays, including errors. 2023-03-28 09:01:26 -04:00
Vitor Pamplona
ceab807f98 BugFix for cutting links in half when they are the last word in a post. 2023-03-28 08:59:08 -04:00
Vitor Pamplona
6b61132f15 Makes the Scan QR Button available in smaller screens. 2023-03-28 08:47:39 -04:00
Vitor Pamplona
cdcf6f9b0a Adds PoW Display to notes 2023-03-28 08:46:07 -04:00
toadlyBroodle
573cfa8b75 add expandable FAB compose menu,
increase bolt size of poll icon
2023-03-28 14:09:07 +09:00
Vitor Pamplona
e1f2313054 v0.30.1 2023-03-27 10:10:04 -04:00
Vitor Pamplona
23a99f3a8c Merge remote-tracking branch 'origin/HEAD' 2023-03-27 09:59:04 -04:00
Vitor Pamplona
53393a350c Fixes Possible crash when typing invalid keys 2023-03-27 09:58:45 -04:00
Vitor Pamplona
56e83d3fe3 Adds special private key for Wallet Connect 2023-03-27 09:54:01 -04:00
believethehype
8aa545f920 Merge branch 'vitorpamplona:main' into main 2023-03-26 23:58:40 +02:00
Believethehype
5f459ac924 Custom Messages for Zaps
- Includes adding a custom message to sats in Backend Code

- Added a simple window on double tap on the bolt symbol for custom amount + message

- ! The popup might need some beautification

- ! Messages are not shown yet in Amethyst

- ! Window could provide options for non-zaps and private zaps in the future
2023-03-26 23:57:25 +02:00
Vitor Pamplona
5ce35363d9 Merge pull request #318 from believethehype/main
Show "follow back" on a user's profile when this user already is a follower
2023-03-26 17:24:44 -04:00
Believethehype
913fa4ba5b Show follow back on profile when profile already follows user 2023-03-26 21:24:13 +02:00
toadlyBroodle
b65139f520 only allow one vote per option on atomic (main==max) polls
fix buggy click event on disabled zap button
2023-03-26 14:34:32 +09:00
toadlyBroodle
62ff9ac94b color option progress bar green when consensus achieved,
add padding to option text
2023-03-26 13:52:27 +09:00
toadlyBroodle
584c2860e4 move option tally logic to PollNoteViewModel 2023-03-26 09:51:00 +09:00
toadlyBroodle
12a1c3fe6d add option tally percentage bars,
create new PollNoteViewModel for each PollNote,
fix isVoteAmountAtomic to directly launch wallet
2023-03-25 15:46:54 +09:00
toadlyBroodle
5b1e7c3451 don't pass in context to voteAmountPlaceHolderText() 2023-03-25 09:18:13 +09:00
toadlyBroodle
9b9cc092dc move PollNote logic to PollNoteViewModel,
fix PollNoteEvent tag getter funs for the final fucking time (hopefully)!,
only show option amounts if user has zapped poll note,
make poll options wider
2023-03-25 08:59:11 +09:00
toadlyBroodle
712c8ab2dd fix isPollOptionZappedBy fun,
option zap widgets layout improvements
2023-03-24 20:26:58 +09:00
toadlyBroodle
dbf0256b1c add zap vote amount dialog,
show vote option tallies,
format zapped vote button icons
2023-03-24 18:10:02 +09:00
toadlyBroodle
171a7841b3 add zap buttons to PollNote options,
add poll_option tag and handlers to LnZapEvents and zap funs,
2023-03-23 19:11:07 +09:00
toadlyBroodle
611dcc0189 implement new NIP69 poll_option tags format List<List<String>>,
fix PollNoteEvent field getter return types
2023-03-23 16:18:16 +09:00
toadlyBroodle
ef31f56eab add PollNote type to ThreadFeedView,
simplify PollNote() arguments,
use parent's backgroundColor,
breakout PollNote modifier
2023-03-21 20:52:30 +09:00
toadlyBroodle
771cdd6ebe add PollNote to ui.note,
simplify code for displaying PollNoteEvents with replyTos or mentions,
fix pollOptions (and other poll fields) get from tags functions
2023-03-21 20:05:53 +09:00
toadlyBroodle
d1f61c0bba add PollNoteEvent type to all relevant filters, dataSources, etc. 2023-03-20 17:38:14 +09:00
toadlyBroodle
5d04f3ea99 enable send poll event,
add PollNoteEvent.kind to DataSources,
add consume(PollNoteEvent) to LocalCache,
2023-03-20 15:36:12 +09:00
toadlyBroodle
bd37e4a9df fix add new poll option button onclick,
prefix all poll composables with 'New',
move all poll composables to actions folder,
move jsonToPollOptions parser to PollNoteEvent
2023-03-20 10:20:39 +09:00
toadlyBroodle
3cdfdfa8e8 rewrite pollOptions json parser 2023-03-20 09:52:23 +09:00
toadlyBroodle
8171c1ee5e change pollOptions to type Map<Int, String>,
remove always true condition from Account.isAcceptable()
2023-03-19 22:15:00 +09:00
toadlyBroodle
0366f6235c move valid input flags to pollViewModel,
disable sendPoll button unless all fields valid
2023-03-19 14:08:37 +09:00
toadlyBroodle
7890ac9db5 create pollSend funs, 2023-03-19 12:43:15 +09:00
toadlyBroodle
7a53708ccc add fields to pollNoteEvent,
add clearState fun to pollViewModel,
add refined PollNoteEvent to Event,
2023-03-18 15:38:58 +09:00
toadlyBroodle
551ed64e98 add PollNoteEvent type 2023-03-17 16:57:17 +09:00
toadlyBroodle
f0b4660719 validate user input for all poll fields,
add user's pubkey to recipients field,
add poll field saveable texts to pollViewModel,
2023-03-17 16:48:26 +09:00
toadlyBroodle
29c4c42547 delete pollOption values on cancel(),
move recipients field to optional section,
make recipients, options textFields fillMaxWidth
2023-03-16 19:25:54 +09:00
toadlyBroodle
107e09aefb move pollOptions list to pollViewModel,
add/delete poll options functionality,
don't allow deleting first 2 poll options
2023-03-16 19:06:00 +09:00
toadlyBroodle
63ad7fd205 add PollClosing component,
rename poll strings/values
2023-03-13 17:33:00 +09:00
toadlyBroodle
c31b99b1dc add consensus threshold component/preview,
change value range placeholder strings
2023-03-13 16:32:38 +09:00
toadlyBroodle
851ec71f4a add PollVoteValueRange component,
add PollOption, PollVoteValueRange component previews,
rename poll_recipients string
2023-03-13 15:46:48 +09:00
toadlyBroodle
e30b4f7c7e add PollRecipientsField,
add buttons for add/delete options,
format poll text fields,
change poll option placeholder string
rename primaryPollDescription string
2023-03-13 14:39:40 +09:00
toadlyBroodle
00f9f7ba52 add PollPrimaryDescription, PollOption objects 2023-03-13 13:15:40 +09:00
toadlyBroodle
564f926213 remove closePollButton 2023-03-11 18:57:02 +09:00
toadlyBroodle
0cf891bb4d pollViewModel -> override postViewModel funs,
remove imageUpload from pollView,
2023-03-11 18:16:47 +09:00
toadlyBroodle
fba873dab9 add floating action buttons column 2023-03-11 16:57:09 +09:00
toadlyBroodle
322853f696 ignore .idea/deploymentTargetDropDown, remove duplicate ignore rules 2023-03-11 15:22:33 +09:00
toadlyBroodle
19c3421e92 ignore .idea/deploymentTargetDropDown 2023-03-11 15:20:25 +09:00
toadlyBroodle
206d6c68bb Add new poll button, view, viewModel, icons, string; replace fab with pollButton; supress missing translations errors when debugging 2023-03-11 15:13:02 +09:00
254 changed files with 17452 additions and 4630 deletions

View File

@@ -14,10 +14,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v1
with:
java-version: 11
java-version: 17
- name: Cache gradle
uses: actions/cache@v1

View File

@@ -12,10 +12,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v1
with:
java-version: 11
java-version: 17
- name: Cache gradle
uses: actions/cache@v1

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetDropDown.xml
.DS_Store
/build
/captures

View File

@@ -1,12 +1,12 @@
# Amethyst Privacy Policy & Terms of Use
Effective as of Jan 20, 2023.
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.

View File

@@ -4,6 +4,15 @@
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
@@ -23,7 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Relay Sets (home, dms, public chats, global)
- [x] User/Note Tagging (NIP-08, NIP-10)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
- [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Identity Verification (NIP-05)
@@ -32,13 +41,25 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtags
- [x] Hashtag Following and Custom Hashtags
- [x] Polls (NIP-69)
- [x] Verifiable static content in URLs (NIP-94)
- [x] Login with QR
- [x] Wallet Connect API (NIP-47)
- [x] Accessible uploads
- [x] Bounty support (nostrbounties.com)
- [x] De-googled F-Droid flavor
- [x] External Identity Support (NIP-39)
- [x] Multiple Accounts
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Relay Authentication (NIP-42)
- [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
@@ -51,6 +72,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Message Sent feedback (NIP-20)
# Development Overview
## Overall Architecture
@@ -75,7 +97,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
## Setup
Make sure to have the following pre-requisites installed:
1. Java 11
1. Java 17
2. Android Studio
3. Android 8.0+ Phone or Emulation setup
@@ -105,8 +127,15 @@ Build the app:
```
## Installing on device
For the F-Droid build:
```bash
./gradlew installDebug
./gradlew installFdroidDebug
```
For the Play build:
```bash
./gradlew installPlayDebug
```
## How to Deploy
@@ -150,6 +179,12 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
## Contributors
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
</a>
# MIT License
Copyright (c) 2023 Vitor Pamplona

View File

@@ -2,6 +2,7 @@ plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
id 'com.google.gms.google-services'
}
android {
@@ -12,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 107
versionName "0.30.0"
versionCode 156
versionName "0.46.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -57,12 +58,12 @@ android {
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '11'
jvmTarget = '17'
}
buildFeatures {
@@ -78,11 +79,15 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
lint {
disable 'MissingTranslation'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-compose:1.7.1'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -104,13 +109,13 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
implementation "net.engawapg.lib:zoomable:1.4.0"
implementation 'net.engawapg.lib:zoomable:1.4.2'
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -123,34 +128,33 @@ dependencies {
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.14.2'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.4'
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
// Load images from the web.
implementation "io.coil-kt:coil-compose:2.2.2"
implementation "io.coil-kt:coil-compose:$coil_version"
// view gifs
implementation "io.coil-kt:coil-gif:2.2.2"
implementation "io.coil-kt:coil-gif:$coil_version"
// view svgs
implementation("io.coil-kt:coil-svg:2.2.2")
implementation "io.coil-kt:coil-svg:$coil_version"
// create blurhash
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// tabs for user profiles
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
// Parses URLs from Text:
implementation "io.github.url-detector:url-detector:0.1.23"
@@ -169,12 +173,16 @@ dependencies {
// Google services model the translate text
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2'
testImplementation "io.mockk:mockk:1.13.4"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
testImplementation 'io.mockk:mockk:1.13.5'
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"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"

68
app/google-services.json Normal file
View File

@@ -0,0 +1,68 @@
{
"project_info": {
"project_number": "768341258853",
"project_id": "amethyst-3057a",
"storage_bucket": "amethyst-3057a.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c",
"android_client_info": {
"package_name": "com.vitorpamplona.amethyst"
}
},
"oauth_client": [
{
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:768341258853:android:e0200680f552484d6b8c8c",
"android_client_info": {
"package_name": "com.vitorpamplona.amethyst.debug"
}
},
"oauth_client": [
{
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View File

@@ -0,0 +1,89 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import junit.framework.TestCase.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CitationTests {
val json = """
{
"content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
"created_at": 1683596206,
"id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758",
"kind": 1,
"pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d",
"sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
"tags": [
[
"e",
"27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3",
"",
"root"
],
[
"e",
"be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc",
"",
"reply"
],
[
"p",
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
],
[
"p",
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
],
[
"p",
"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
],
[
"p",
"ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
],
[
"p",
"0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
],
[
"p",
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
],
[
"p",
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
],
[
"p",
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
],
[
"p",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
]
],
"seenOn": [
"wss://nostr.wine/"
]
}
"""
@Test
fun parseEvent() {
val event = Event.fromJson(json, true) as TextNoteEvent
val expectedCitations = setOf(
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168",
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
)
assertEquals(expectedCitations, event.citedUsers())
}
}

View File

@@ -0,0 +1,113 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
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 kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
@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 {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
ImgurServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(5000)
}
@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)
}
@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)
}
@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)
}
}

View File

@@ -0,0 +1,138 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.toHexKey
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.LnZapRequestEvent.Companion.createEncryptionPrivateKey
import fr.acinq.secp256k1.Hex
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PrivateZapTests {
@Test
fun testPollZap() {
val poll = Event.Companion.fromJson(
"""{
"content": "New poll \n\n #zappoll",
"created_at": 1682440713,
"id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288",
"kind": 6969,
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
"sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279",
"tags": [
[
"poll_option",
"0",
"Test 1"
],
[
"poll_option",
"1",
"Test 2"
],
[
"value_maximum",
"null"
],
[
"value_minimum",
"null"
],
[
"consensus_threshold",
"null"
],
[
"closed_at",
"null"
]
],
"seenOn": [
"wss://relay.damus.io/"
]
}
""",
true
)
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
val privateZapRequest = LnZapRequestEvent.create(
poll,
setOf("wss://relay.damus.io/"),
loggedIn,
0,
"",
LnZapEvent.ZapType.PRIVATE
)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
@Test
fun testKind1PrivateZap() {
val textNote = Event.Companion.fromJson(
"""{
"content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
"created_at": 1682369982,
"id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1",
"kind": 1,
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
"sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1",
"tags": [
[
"p",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
]
],
"seenOn": [
"wss://relay.damus.io/"
]
}
""",
true
)
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
val privateZapRequest = LnZapRequestEvent.create(
textNote,
setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
loggedIn,
null,
"test",
LnZapEvent.ZapType.PRIVATE
)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
}

View File

@@ -0,0 +1,8 @@
package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.AccountInfo
class PushNotificationUtils {
fun init(accounts: List<AccountInfo>) {
}
}

View File

@@ -1,26 +1,26 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) = ExpandableRichTextViewer(
content,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) = ExpandableRichTextViewer(
content,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)

View File

@@ -1,11 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="org.torproject.android"/>
</queries>
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -16,7 +22,7 @@
android:name=".Amethyst"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:enableOnBackInvokedCallback="true"
android:supportsRtl="true"
@@ -43,6 +49,20 @@
<data android:scheme="nostr" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="nostr" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrwalletconnect" />
<data android:scheme="nostr+walletconnect" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
@@ -52,6 +72,7 @@
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -6,12 +6,17 @@ import android.content.SharedPreferences
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.model.Contact
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.HttpClient
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.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.toHex
@@ -27,10 +32,7 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val current: Boolean,
val displayName: String?,
val profilePicture: String?
val hasPrivKey: Boolean = false
)
private object PrefKeys {
@@ -38,8 +40,6 @@ private object PrefKeys {
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val DISPLAY_NAME = "display_name"
const val PROFILE_PICTURE_URL = "profile_picture"
const val FOLLOWING_CHANNELS = "following_channels"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
@@ -47,10 +47,17 @@ private object PrefKeys {
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val DEFAULT_ZAPTYPE = "defaultZapType"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
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 USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -59,53 +66,73 @@ private val gson = GsonBuilder().create()
object LocalPreferences {
private const val comma = ","
private var currentAccount: String?
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
set(npub) {
val prefs = encryptedPreferences()
prefs.edit().apply {
private var _currentAccount: String? = null
private fun currentAccount(): String? {
if (_currentAccount == null) {
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
return _currentAccount
}
private fun updateCurrentAccount(npub: String) {
if (_currentAccount != npub) {
_currentAccount = npub
encryptedPreferences().edit().apply {
putString(PrefKeys.CURRENT_ACCOUNT, npub)
}.apply()
}
}
private val savedAccounts: List<String>
get() = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
private var _savedAccounts: List<String>? = null
private fun savedAccounts(): List<String> {
if (_savedAccounts == null) {
_savedAccounts = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
}
return _savedAccounts!!
}
private fun updateSavedAccounts(accounts: List<String>) {
if (_savedAccounts != accounts) {
_savedAccounts = accounts
encryptedPreferences().edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
}
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
private fun addAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
val accounts = savedAccounts().toMutableList()
if (npub !in accounts) {
accounts.add(npub)
updateSavedAccounts(accounts)
}
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
private fun setCurrentAccount(account: Account) {
val npub = account.userProfile().pubkeyNpub()
currentAccount = npub
updateCurrentAccount(npub)
addAccount(npub)
}
fun switchToAccount(npub: String) {
currentAccount = npub
updateCurrentAccount(npub)
}
/**
* Removes the account from the app level shared preferences
*/
private fun removeAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
val accounts = savedAccounts().toMutableList()
if (accounts.remove(npub)) {
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
updateSavedAccounts(accounts)
}
}
@@ -145,11 +172,11 @@ object LocalPreferences {
removeAccount(npub)
deleteUserPreferenceFile(npub)
if (savedAccounts.isEmpty()) {
if (savedAccounts().isEmpty()) {
val appPrefs = encryptedPreferences()
appPrefs.edit().clear().apply()
} else if (currentAccount == npub) {
currentAccount = savedAccounts.elementAt(0)
} else if (currentAccount() == npub) {
updateCurrentAccount(savedAccounts().elementAt(0))
}
}
@@ -159,17 +186,8 @@ object LocalPreferences {
}
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts.map { npub ->
val prefs = encryptedPreferences(npub)
val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
AccountInfo(
npub = npub,
hasPrivKey = hasPrivKey,
current = npub == currentAccount,
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
)
return savedAccounts().map { npub ->
AccountInfo(npub = npub)
}
}
@@ -185,17 +203,28 @@ object LocalPreferences {
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
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.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences(currentAccount).apply {
val acc = loadFromEncryptedStorage(currentAccount())
acc?.registerObservers()
return acc
}
fun loadFromEncryptedStorage(npub: String?): Account? {
encryptedPreferences(npub).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
@@ -207,15 +236,28 @@ object LocalPreferences {
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val defaultZapType = gson.fromJson(
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
object : TypeToken<LnZapEvent.ZapType>() {}.type
) ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = gson.fromJson(
getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
object : TypeToken<ServersAvailable>() {}.type
) ?: ServersAvailable.IMGUR
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Contact::class.java)
gson.fromJson(it, Nip47URI::class.java)
}
} catch (e: Throwable) {
e.printStackTrace()
@@ -246,9 +288,12 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_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)
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
val a = Account(
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels,
hiddenUsers,
localRelays,
@@ -256,28 +301,37 @@ object LocalPreferences {
languagePreferences,
translateTo,
zapAmountChoices,
defaultZapType,
defaultFileServer,
defaultHomeFollowList,
defaultStoriesFollowList,
defaultNotificationFollowList,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList
latestContactList,
proxy,
proxyPort
)
return a
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences(currentAccount).edit().apply {
encryptedPreferences(currentAccount()).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences(currentAccount).run {
encryptedPreferences(currentAccount()).run {
return getLong(PrefKeys.LAST_READ(route), 0)
}
}
fun migrateSingleUserPrefs() {
if (currentAccount != null) return
if (currentAccount() != null) return
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
val npub = Hex.decode(pubkey).toNpub()
@@ -314,6 +368,6 @@ object LocalPreferences {
encryptedPreferences().edit().clear().apply()
addAccount(npub)
currentAccount = npub
updateCurrentAccount(npub)
}
}

View File

@@ -38,19 +38,15 @@ object NotificationCache {
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
refresh()
postValue(NotificationState(cache))
}
}
fun invalidateData() {
bundler.invalidate()
}
fun refresh() {
postValue(NotificationState(cache))
}
}
class NotificationState(val cache: NotificationCache)

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
@@ -12,6 +13,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
@@ -25,7 +27,7 @@ object ServiceManager {
fun start() {
val myAccount = account
HttpClient.start(account)
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
@@ -33,10 +35,11 @@ object ServiceManager {
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
// Notification Elements
NostrAccountDataSource.start()
NostrHomeDataSource.start()
NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
// More Info Data Sources
@@ -61,6 +64,7 @@ object ServiceManager {
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
NostrVideoDataSource.stop()
Client.disconnect()
}

View File

@@ -18,7 +18,7 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
fun get(context: Context): CacheDataSource.Factory {
fun init(context: Context) {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -35,7 +35,9 @@ object VideoCache {
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
}
fun get(): CacheDataSource.Factory {
return cacheDataSourceFactory
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.Dispatchers
@@ -14,7 +15,7 @@ class AntiSpamFilter {
val spamMessages = LruCache<Int, Spammer>(1000)
@Synchronized
fun isSpam(event: Event): Boolean {
fun isSpam(event: Event, relay: Relay?): Boolean {
val idHex = event.id
// if short message, ok
@@ -27,7 +28,7 @@ class AntiSpamFilter {
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
// Log down offenders
if (spamMessages.get(hash) == null) {
@@ -54,19 +55,15 @@ class AntiSpamFilter {
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
refresh()
postValue(AntiSpamState(cache))
}
}
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(AntiSpamState(cache))
}
}
class AntiSpamState(val cache: AntiSpamFilter)

View File

@@ -74,9 +74,9 @@ class Channel(val idHex: String) {
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
refresh()
postValue(ChannelState(channel))
}
}
@@ -84,10 +84,6 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
bundler.invalidate()
}
private fun refresh() {
postValue(ChannelState(channel))
}
override fun onActive() {
super.onActive()
NostrSingleChannelDataSource.add(channel.idHex)

View File

@@ -6,15 +6,16 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches", "nostrichs" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
else -> null
}
}

View File

@@ -29,7 +29,7 @@ fun ByteArray.toHexKey(): HexKey {
return toHex()
}
fun HexKey.toByteArray(): ByteArray {
fun HexKey.hexToByteArray(): ByteArray {
return Hex.decode(this)
}
@@ -38,14 +38,14 @@ fun HexKey.toDisplayHexKey(): String {
}
fun decodePublicKey(key: String): ByteArray {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.hexToByteArray()
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
} else if (key.startsWith("npub")) {
key.bechToBytes()
} else if (key.startsWith("note")) {
key.bechToBytes()
} else { // if (pattern.matcher(key).matches()) {
// } else {
} else if (pubKeyParsed != null) {
pubKeyParsed
} else {
Hex.decode(key)
}
}
@@ -84,15 +84,15 @@ fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(pubkeyRelay, restOfWord)
} else if (key.startsWith("nevent", true)) {
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
} else if (key.startsWith("nevent1", true)) {
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(noteRelayId, restOfWord)
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
} else if (key.startsWith("naddr1", true)) {
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(address, "") // no way to know when they address ends and dirt begins
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
}
} catch (e: Exception) {
e.printStackTrace()

View File

@@ -1,53 +1,43 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.core.net.toUri
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
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.Amethyst
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import nostr.postr.toNpub
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
object LocalCache {
val metadataParser = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>()
val users = ConcurrentHashMap<HexKey, User>(5000)
val notes = ConcurrentHashMap<HexKey, Note>(5000)
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
val awaitingPaymentRequests =
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
@@ -170,6 +160,20 @@ object LocalCache {
}
}
fun consume(event: PeopleListEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// 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"))
@@ -187,7 +191,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event)) {
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
@@ -208,7 +212,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: LongTextNoteEvent, relay: Relay?) {
@@ -223,7 +227,7 @@ object LocalCache {
// Already processed this event.
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event)) {
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
@@ -237,10 +241,46 @@ object LocalCache {
author.addNote(note)
refreshObservers()
refreshObservers(note)
}
}
fun consume(event: PollNoteEvent, relay: Relay? = null) {
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
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.addNote(note)
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers(note)
}
fun consume(event: BadgeDefinitionEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -251,7 +291,7 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<Note>())
refreshObservers()
refreshObservers(note)
}
}
@@ -269,8 +309,6 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
author.updateAcceptedBadges(note)
refreshObservers()
}
}
@@ -292,7 +330,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
@@ -302,17 +340,15 @@ object LocalCache {
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
val follows = event.unverifiedFollowKeySet()
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !follows.isNullOrEmpty()) {
// Saves relay list only if it's a user that is currently been seen
// avoids processing empty contact lists.
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
user.updateContactList(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
}
fun consume(event: PrivateDmEvent, relay: Relay?) {
fun consume(event: PrivateDmEvent, relay: Relay?): Note {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -322,9 +358,9 @@ object LocalCache {
}
// Already processed this event.
if (note.event != null) return
if (note.event != null) return note
val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) }
val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
@@ -338,7 +374,9 @@ object LocalCache {
recipient.addMessage(author, note)
}
refreshObservers()
refreshObservers(note)
return note
}
fun consume(event: DeletionEvent) {
@@ -363,6 +401,7 @@ object LocalCache {
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
masterNote.removeZapPayment(deleteNote)
masterNote.removeReport(deleteNote)
}
@@ -371,7 +410,7 @@ object LocalCache {
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
val recipient = (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { checkGetOrCreateUser(it) }
if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote)
@@ -386,7 +425,7 @@ object LocalCache {
}
if (deletedAtLeastOne) {
live.invalidateData()
// refreshObservers()
}
}
@@ -412,7 +451,7 @@ object LocalCache {
it.addBoost(note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: ReactionEvent) {
@@ -450,6 +489,8 @@ object LocalCache {
it.addReport(note)
}
}
refreshObservers(note)
}
fun consume(event: ReportEvent, relay: Relay?) {
@@ -480,6 +521,8 @@ object LocalCache {
repliesTo.forEach {
it.addReport(note)
}
refreshObservers(note)
}
fun consume(event: ChannelCreateEvent) {
@@ -496,7 +539,7 @@ object LocalCache {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers()
refreshObservers(note)
}
}
@@ -516,7 +559,7 @@ object LocalCache {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers()
refreshObservers(note)
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -543,16 +586,16 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event)) {
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
return
}
val replyTo = event.replyTos()
val replyTo = event.tagsWithoutCitations()
.filter { it != event.channel() }
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.loadEvent(event, author, replyTo)
@@ -563,7 +606,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
@@ -576,19 +619,17 @@ object LocalCache {
fun consume(event: LnZapEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
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)
@@ -606,6 +647,8 @@ object LocalCache {
mentions.forEach {
it.addZap(zapRequest, note)
}
refreshObservers(note)
}
fun consume(event: LnZapRequestEvent) {
@@ -629,6 +672,142 @@ object LocalCache {
mentions.forEach {
it.addZap(note, null)
}
refreshObservers(note)
}
fun consume(event: FileHeaderEvent, 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: FileStorageHeaderEvent, 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: HighlightEvent, 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())
// Adds to user profile
author.addNote(note)
refreshObservers(note)
}
fun consume(event: FileStorageEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
if (!file.exists()) {
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val stream = FileOutputStream(File(cachePath, event.id))
stream.write(event.decode())
stream.close()
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
} catch (e: IOException) {
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
}
}
// Already processed this event.
if (note.event != null) return
// this is an invalid event. But we don't need to keep the data in memory.
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
note.loadEvent(eventNoData, author, emptyList())
refreshObservers(note)
}
fun consume(event: LnZapPaymentRequestEvent) {
// Does nothing without a response callback.
}
fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
zappedNote?.addZapPayment(note, null)
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
refreshObservers(note)
}
fun consume(event: LnZapPaymentResponseEvent) {
val requestId = event.requestId()
val pair = awaitingPaymentRequests[requestId] ?: return
val (zappedNote, responseCallback) = pair
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
requestNote?.let { request ->
zappedNote?.addZapPayment(request, note)
}
if (responseCallback != null) {
responseCallback(event)
}
}
fun findUsersStartingWith(username: String): List<User> {
@@ -642,6 +821,7 @@ object LocalCache {
fun findNotesStartingWith(text: String): List<Note> {
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is ChannelMessageEvent && it.event?.content()?.contains(text, true) ?: false) ||
it.idHex.startsWith(text, true) ||
it.idNote().startsWith(text, true)
@@ -679,11 +859,6 @@ object LocalCache {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// reverts the add
val mentions =
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
@@ -734,30 +909,77 @@ object LocalCache {
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
val live: LocalCacheLiveData = LocalCacheLiveData()
private fun refreshObservers() {
live.invalidateData()
private fun refreshObservers(newNote: Note) {
live.invalidateData(newNote)
}
}
class LocalCacheLiveData(val cache: LocalCache) :
LiveData<LocalCacheState>(LocalCacheState(cache)) {
fun consume(event: Event, relay: Relay?) {
if (!event.hasValidSignature()) return
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
try {
when (event) {
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
is BadgeProfilesEvent -> consume(event)
is BookmarkListEvent -> consume(event)
is ChannelCreateEvent -> consume(event)
is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)
is FileHeaderEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
consume(it, relay)
}
consume(event)
}
is LnZapRequestEvent -> consume(event)
is LnZapPaymentRequestEvent -> consume(event)
is LnZapPaymentResponseEvent -> consume(event)
is LongTextNoteEvent -> consume(event, relay)
is MetadataEvent -> consume(event)
is PrivateDmEvent -> consume(event, relay)
is PeopleListEvent -> consume(event)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is ReportEvent -> consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let {
consume(it, relay)
}
consume(event)
}
is TextNoteEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(LocalCacheState(cache))
}
}
class LocalCacheState(val cache: LocalCache)
class LocalCacheLiveData {
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
// Refreshes observers in batches.
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
_newEventBundles.emit(bundledNewNotes)
}
}
}

View File

@@ -2,7 +2,10 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
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.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -19,6 +22,7 @@ val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun toNEvent() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun address() = address
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
@@ -42,23 +46,31 @@ open class Note(val idHex: String) {
private set
var zaps = mapOf<Note, Note?>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
var relays = setOf<String>()
private set
var lastReactionsDownloadTime: Map<String, Long> = emptyMap()
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun toNEvent(): String {
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
open fun idDisplayNote() = idNote().toShortenHex()
fun channel(): Channel? {
val channelHex =
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.let { it.id }
fun channelHex(): HexKey? {
return (event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
}
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
fun channel(): Channel? {
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
}
open fun address(): ATag? = null
@@ -147,6 +159,17 @@ open class Note(val idHex: String) {
}
}
fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) {
zapPayments = zapPayments.minus(note)
liveSet?.zaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
@@ -154,6 +177,7 @@ open class Note(val idHex: String) {
}
}
@Synchronized
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
@@ -164,6 +188,17 @@ open class Note(val idHex: String) {
}
}
@Synchronized
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
}
}
fun addReaction(note: Note) {
if (note !in reactions) {
reactions = reactions + note
@@ -190,17 +225,27 @@ open class Note(val idHex: String) {
}
}
fun isZappedBy(user: User): Boolean {
fun isZappedBy(user: User, account: Account): Boolean {
// Zaps who the requester was the user
return zaps.any { it.key.author == user }
return zaps.any {
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
} || zapPayments.any {
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
val response = if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent)
} else {
null
}
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
}
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author == user }
return reactions.any { it.author === user }
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author == user }
return boosts.any { it.author === user }
}
fun reportsBy(user: User): Set<Note> {
@@ -221,12 +266,44 @@ open class Note(val idHex: String) {
}.flatten()
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
.filter { it.amount != null }
.associate {
it.lnInvoice() to it.amount
}
.toMap()
val completedPayments = if (privKey != null && walletServicePubkey != null) {
// Payments confirmed by the User's Wallet
zapPayments
.asSequence()
.filter {
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
response is PayInvoiceSuccessResponse
}
.associate {
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
val amount = try {
if (lnInvoice == null) {
null
} else {
LnInvoiceUtil.getAmountInSats(lnInvoice)
}
} catch (e: java.lang.Exception) {
null
}
lnInvoice to amount
}
.toMap()
} else {
emptyMap()
}
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
}
fun hasPledgeBy(user: User): Boolean {
@@ -268,45 +345,6 @@ open class Note(val idHex: String) {
)
}
fun directlyCiteUsersHex(): Set<HexKey> {
val matcher = tagSearch.matcher(event?.content() ?: "")
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCiteUsers(): Set<User> {
val matcher = tagSearch.matcher(event?.content() ?: "")
val returningList = mutableSetOf<User>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
returningList.add(it)
}
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCites(userProfile: User): Boolean {
return author == userProfile ||
(userProfile in directlyCiteUsers()) ||
(event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) ||
(event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
}
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}
@@ -372,20 +410,16 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
}
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// if (hasObservers()) {
postValue(NoteState(note))
// }
}
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(NoteState(note))
}
override fun onActive() {
super.onActive()
if (note is AddressableNote) {

View File

@@ -6,7 +6,7 @@ import kotlin.time.measureTimedValue
class ThreadAssembler {
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
testedNotes.add(note)
@@ -53,6 +53,11 @@ class ThreadAssembler {
val threadRoot = searchRoot(note, thread) ?: note
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach {
loadDown(it, thread)
}
thread.toSet()
} else {

View File

@@ -3,10 +3,6 @@ package com.vitorpamplona.amethyst.model
import com.baha.url.preview.BahaUrlPreview
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.imageExtension
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.videoExtension
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -47,29 +43,4 @@ object UrlCachedPreviewer {
).fetchUrlPreview()
}
}
fun findUrlsInMessage(message: String): List<String> {
return message.split('\n').map { paragraph ->
paragraph.split(' ').filter { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
}
}.flatten()
}
fun preloadPreviewsFor(note: Note) {
note.event?.content()?.let {
findUrlsInMessage(it).forEach {
val removedParamsFromUrl = it.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
// Preload Images? Isn't this too heavy?
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
// Do nothing for now.
} else if (isValidURL(removedParamsFromUrl)) {
previewInfo(it)
} else {
previewInfo("https://$it")
}
}
}
}
}

View File

@@ -7,6 +7,7 @@ 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.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -31,7 +32,7 @@ class User(val pubkeyHex: String) {
var reports = mapOf<User, Set<Note>>()
private set
var latestEOSEs: Map<String, Long> = emptyMap()
var latestEOSEs: Map<String, EOSETime> = emptyMap()
var zaps = mapOf<Note, Note?>()
private set
@@ -46,6 +47,7 @@ class User(val pubkeyHex: String) {
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
override fun toString(): String = pubkeyHex
@@ -179,7 +181,7 @@ class User(val pubkeyHex: String) {
}
@Synchronized
fun getOrCreatePrivateChatroom(user: User): Chatroom {
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
@@ -272,7 +274,7 @@ class User(val pubkeyHex: String) {
}
fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
@@ -288,13 +290,13 @@ class User(val pubkeyHex: String) {
}
fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.roomMessages.any { this == it.author }
return messagesToUser.roomMessages.any { this === it.author }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
@@ -386,13 +388,17 @@ class UserMetadata {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.startsWith(prefix, true) }
}
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
refresh()
postValue(UserState(user))
}
}
@@ -400,10 +406,6 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
bundler.invalidate()
}
private fun refresh() {
postValue(UserState(user))
}
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user)

View File

@@ -0,0 +1,203 @@
package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.Color
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign
object BlurHashDecoder {
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
private val cacheCosinesX = HashMap<Int, DoubleArray>()
private val cacheCosinesY = HashMap<Int, DoubleArray>()
/**
* Clear calculations stored in memory cache.
* The cache is not big, but will increase when many image sizes are used,
* if the app needs memory it is recommended to clear it.
*/
fun clearCache() {
cacheCosinesX.clear()
cacheCosinesY.clear()
}
/**
* Returns width/height
*/
fun aspectRatio(blurHash: String?): Float? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
return numCompX.toFloat() / numCompY.toFloat()
}
/**
* Decode a blur hash into a new bitmap.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations.
* By default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
val maxAcEnc = decode83(blurHash, 1, 2)
val maxAc = (maxAcEnc + 1) / 166f
val colors = Array(numCompX * numCompY) { i ->
if (i == 0) {
val colorEnc = decode83(blurHash, 2, 6)
decodeDc(colorEnc)
} else {
val from = 4 + i * 2
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
}
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
}
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
var result = 0
for (i in from until to) {
val index = charMap[str[i]] ?: -1
if (index != -1) {
result = result * 83 + index
}
}
return result
}
private fun decodeDc(colorEnc: Int): FloatArray {
val r = colorEnc shr 16
val g = (colorEnc shr 8) and 255
val b = colorEnc and 255
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
}
private fun srgbToLinear(colorEnc: Int): Float {
val v = colorEnc / 255f
return if (v <= 0.04045f) {
(v / 12.92f)
} else {
((v + 0.055f) / 1.055f).pow(2.4f)
}
}
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9.0f) * maxAc,
signedPow2((g - 9) / 9.0f) * maxAc,
signedPow2((b - 9) / 9.0f) * maxAc
)
}
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
private fun composeBitmap(
width: Int,
height: Int,
numCompX: Int,
numCompY: Int,
colors: Array<FloatArray>,
useCache: Boolean
): Bitmap {
// use an array for better performance when writing pixel colors
val imageArray = IntArray(width * height)
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
for (y in 0 until height) {
for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numCompY) {
for (i in 0 until numCompX) {
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
val basis = (cosX * cosY).toFloat()
val color = colors[j * numCompX + i]
r += color[0] * basis
g += color[1] * basis
b += color[2] * basis
}
}
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
}
}
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
calculate -> {
DoubleArray(height * numCompY).also {
cacheCosinesY[height * numCompY] = it
}
}
else -> {
cacheCosinesY[height * numCompY]!!
}
}
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
calculate -> {
DoubleArray(width * numCompX).also {
cacheCosinesX[width * numCompX] = it
}
}
else -> cacheCosinesX[width * numCompX]!!
}
private fun DoubleArray.getCos(
calculate: Boolean,
x: Int,
numComp: Int,
y: Int,
size: Int
): Double {
if (calculate) {
this[x + numComp * y] = cos(Math.PI * y * x / size)
}
return this[x + numComp * y]
}
private fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0f, 1f)
return if (v <= 0.0031308f) {
(v * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
}
private val charMap = listOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
)
.mapIndexed { i, c -> c to i }
.toMap()
}

View File

@@ -0,0 +1,64 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
class BlurHashFetcher(
private val options: Options,
private val data: Uri
) : Fetcher {
override suspend fun fetch(): FetchResult {
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
val preferredWidth = 100
val bitmap = BlurHashDecoder.decode(
hash,
preferredWidth,
(preferredWidth * (1 / aspectRatio)).roundToInt()
)
if (bitmap == null) {
throw Exception("Unable to convert Bluehash $hash")
}
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return BlurHashFetcher(options, data)
}
}
}
object BlurHashRequester {
fun imageRequest(context: Context, message: String): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.build()
}
}

View File

@@ -0,0 +1,89 @@
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 io.trbl.blurhash.BlurHash
import java.security.MessageDigest
import kotlin.math.roundToInt
class FileHeader(
val url: String,
val mimeType: String?,
val hash: String,
val size: Int,
val dim: String?,
val blurHash: String?,
val description: String? = null
) {
companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
} else {
onError()
}
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError()
}
}
fun prepare(
data: ByteArray,
fileUrl: String,
mimeType: String?,
description: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
try {
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(data).toHexKey()
val size = data.size
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
intArray,
0,
mBitmap.width,
0,
0,
mBitmap.width,
mBitmap.height
)
val dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
} else if (aspectRatio < 1) {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} else {
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
}
}
}
}

View File

@@ -0,0 +1,34 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
object HttpClient {
private var proxy: Proxy? = null
fun start(account: Account?) {
this.proxy = account?.proxy
}
fun getHttpClient(): OkHttpClient {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.build()
}
fun getProxy(): Proxy? {
return proxy
}
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
}
}

View File

@@ -9,12 +9,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier {
val client = OkHttpClient.Builder().build()
class Nip05Verifier() {
val client = HttpClient.getHttpClient()
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")

View File

@@ -1,18 +1,20 @@
package com.vitorpamplona.amethyst.ui.note
import android.net.Uri
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.Contact
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
// Rename to the corect nip number when ready.
object Nip47 {
fun parse(uri: String): Contact? {
fun parse(uri: String): Nip47URI {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect") {
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
}
@@ -25,7 +27,8 @@ object Nip47 {
}
val relay = url.getQueryParameter("relay")
val secret = url.getQueryParameter("secret")
return Contact(pubkeyHex, relay)
return Nip47URI(pubkeyHex, relay, secret)
}
}

View File

@@ -1,6 +1,7 @@
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
@@ -12,16 +13,21 @@ 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.service.relays.FeedType
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
object NostrAccountDataSource : NostrDataSource("AccountData") {
lateinit var account: Account
val latestEOSEs = EOSEAccount()
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -32,7 +38,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -43,7 +49,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -54,43 +60,49 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountBookmarkListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
limit = 100
)
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
)
)
}
fun createNotificationFilter() = TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapPaymentResponseEvent.kind,
ChannelMessageEvent.kind,
BadgeAwardEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 200
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
)
)
val accountChannel = requestNewChannel()
val accountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
@@ -103,4 +115,19 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountBookmarkListFilter()
).ifEmpty { null }
}
override fun auth(relay: Relay, challenge: String) {
super.auth(relay, challenge)
if (this::account.isInitialized) {
val event = account.createAuthEvent(relay, challenge)
if (event != null) {
Client.send(
event,
relay.url
)
}
}
}
}

View File

@@ -5,6 +5,8 @@ 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.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
@@ -12,11 +14,15 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
lateinit var account: Account
val latestEOSEs = EOSEAccount()
val chatRoomList = "ChatroomList"
fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
@@ -24,7 +30,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
@@ -32,22 +39,24 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
fun createMyChannelsFilter() = TypedFilter(
types = FeedType.values().toSet(), // Metadata comes from any relay
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
ids = account.followingChannels.toList(),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
fun createLastChannelInfoFilter(): List<TypedFilter> {
return account.followingChannels.map {
TypedFilter(
types = FeedType.values().toSet(), // Metadata comes from any relay
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
@@ -64,13 +73,16 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
limit = 25 // Remember to consider spam that is being removed from the UI
)
)
}
}
val chatroomListChannel = requestNewChannel()
val chatroomListChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
}
override fun updateChannelFilters() {
val list = listOf(

View File

@@ -2,28 +2,8 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.*
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.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
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.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
@@ -34,6 +14,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.Date
import java.util.UUID
import kotlin.Error
abstract class NostrDataSource(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
@@ -50,8 +31,6 @@ abstract class NostrDataSource(val debugName: String) {
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) {
if (!event.hasValidSignature()) return
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
@@ -60,43 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
try {
when (event) {
is BadgeAwardEvent -> LocalCache.consume(event)
is BadgeDefinitionEvent -> LocalCache.consume(event)
is BadgeProfilesEvent -> LocalCache.consume(event)
is BookmarkListEvent -> LocalCache.consume(event)
is ChannelCreateEvent -> LocalCache.consume(event)
is ChannelHideMessageEvent -> LocalCache.consume(event)
is ChannelMessageEvent -> LocalCache.consume(event, relay)
is ChannelMetadataEvent -> LocalCache.consume(event)
is ChannelMuteUserEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is LnZapEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is LnZapRequestEvent -> LocalCache.consume(event)
is LongTextNoteEvent -> LocalCache.consume(event, relay)
is MetadataEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is ReactionEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ReportEvent -> LocalCache.consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is TextNoteEvent -> LocalCache.consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
}
} catch (e: Exception) {
e.printStackTrace()
}
LocalCache.consume(event, relay)
}
}
@@ -120,12 +63,21 @@ abstract class NostrDataSource(val debugName: String) {
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
}
override fun onAuth(relay: Relay, challenge: String) {
auth(relay, challenge)
}
}
init {
Client.subscribe(clientListener)
}
fun destroy() {
stop()
Client.unsubscribe(clientListener)
}
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
resetFilters()
@@ -151,8 +103,8 @@ abstract class NostrDataSource(val debugName: String) {
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(250, Dispatchers.IO) {
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// 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.
@@ -210,4 +162,5 @@ abstract class NostrDataSource(val debugName: String) {
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
}

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
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.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -11,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
limit = 200
)
)

View File

@@ -4,7 +4,7 @@ import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -15,9 +15,16 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
val hashToLoad = hashtagToWatch ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
tags = mapOf(
"t" to listOf(
hashToLoad,
hashToLoad.lowercase(),
hashToLoad.uppercase(),
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)

View File

@@ -2,8 +2,11 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.HighlightEvent
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.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -15,6 +18,8 @@ import kotlinx.coroutines.launch
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
lateinit var account: Account
val latestEOSEs = EOSEAccount()
private val cacheListener: (UserState) -> Unit = {
invalidateFilters()
}
@@ -40,7 +45,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.followingKeySet()
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followKeys = follows.map {
it.substring(0, 6)
@@ -51,33 +56,37 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = followSet,
limit = 400
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.followingTagSet()
val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
val followAccountChannel = requestNewChannel()
val followAccountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
}
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }

View File

@@ -0,0 +1,39 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
class NostrLnZapPaymentResponseDataSource(
private var fromServiceHex: String,
private var toUserHex: String,
private var replyingToHex: String
) : NostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT)
private fun createWalletConnectServiceWatcher(): TypedFilter {
// downloads all the reactions to a given event.
return TypedFilter(
types = feedTypes,
filter = JsonFilter(
kinds = listOf(LnZapPaymentResponseEvent.kind),
authors = listOf(fromServiceHex),
tags = mapOf(
"e" to listOf(replyingToHex),
"p" to listOf(toUserHex)
),
limit = 1
)
)
}
val channel = requestNewChannel()
override fun updateChannelFilters() {
val wc = createWalletConnectServiceWatcher()
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
}
}

View File

@@ -1,12 +1,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
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.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.*
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
@@ -35,27 +31,27 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
null
}
if (hexToWatch == null) {
return null
}
// downloads all the reactions to a given event.
return listOf(
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
return listOfNotNull(
hexToWatch?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
)
)
),
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOfNotNull(hexToWatch)
},
hexToWatch?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOfNotNull(hexToWatch)
)
)
),
},
TypedFilter(
types = FeedType.values().toSet(),
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
@@ -63,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
),
TypedFilter(
types = FeedType.values().toSet(),
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
search = mySearchString,
limit = 20
)

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
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
@@ -40,7 +41,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
// downloads linked events to this event.
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = interestedEvents.toList()

View File

@@ -2,23 +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.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
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.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
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.service.relays.FeedType
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 java.util.Date
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
@@ -31,18 +19,17 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null
}
val now = Date().time / 1000
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime
@@ -59,12 +46,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null
}
val now = Date().time / 1000
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
@@ -82,11 +67,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null
}
val now = Date().time / 1000
return reactionsToWatch.map {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
@@ -95,7 +78,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
RepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind
LnZapRequestEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -124,11 +109,17 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
// downloads linked events to this event.
return listOf(
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind
),
ids = interestedEvents.toList()
)
@@ -138,8 +129,14 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val singleEventChannel = requestNewChannel { time, relayUrl ->
eventsToWatch.forEach {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, time)
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
@@ -151,26 +148,34 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val addresses = createAddressFilter()
val addressReactions = createTagToAddressFilter()
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null }
singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
}
fun add(eventId: Note) {
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
if (!eventsToWatch.contains(eventId)) {
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: Note) {
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
if (eventsToWatch.contains(eventId)) {
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
}
}
fun addAddress(aTag: Note) {
addressesToWatch = addressesToWatch.plus(aTag)
invalidateFilters()
if (!addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.plus(aTag)
invalidateFilters()
}
}
fun removeAddress(aTag: Note) {
addressesToWatch = addressesToWatch.minus(aTag)
invalidateFilters()
if (addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.minus(aTag)
invalidateFilters()
}
}
}

View File

@@ -3,7 +3,8 @@ 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.FeedType
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
@@ -15,7 +16,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -30,7 +31,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
return usersToWatch.map {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),
@@ -42,8 +43,14 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
val userChannel = requestNewChannel() { time, relayUrl ->
usersToWatch.forEach {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, time)
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
@@ -57,12 +64,16 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
}
fun add(user: User) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
if (!usersToWatch.contains(user)) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
}
}
fun remove(user: User) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
if (usersToWatch.contains(user)) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
}
}
}

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -18,14 +18,14 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
.ifEmpty { null } ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
ids = eventsToLoad.map { it.substring(0, 8) }
)
)
}
val loadEventsChannel = requestNewChannel() { eoseTime, relay ->
val loadEventsChannel = requestNewChannel() { _, _ ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()

View File

@@ -1,36 +1,21 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
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.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
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
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(userId: String?) {
if (userId != null) {
user = LocalCache.getOrCreateUser(userId)
} else {
user = null
}
resetFilters()
fun loadUserProfile(user: User?) {
this.user = user
}
fun createUserInfoFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -41,9 +26,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createUserPostsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)
@@ -52,7 +37,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createUserReceivedZapsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(LnZapEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex))
@@ -62,7 +47,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createFollowFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -73,7 +58,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createFollowersFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex))
@@ -83,7 +68,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createAcceptedAwardsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -94,9 +79,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createBookmarksFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
)
@@ -105,7 +90,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createReceivedAwardsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeAwardEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),

View File

@@ -0,0 +1,54 @@
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.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
lateinit var account: Account
fun createContextualFilter(): TypedFilter? {
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200
)
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
)
)
}
val videoFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -10,14 +11,13 @@ import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.math.BigDecimal
import java.net.URLEncoder
class LightningAddressResolver {
val client = OkHttpClient.Builder().build()
class LightningAddressResolver() {
val client = HttpClient.getHttpClient()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Tlv
import fr.acinq.secp256k1.Hex
@@ -14,7 +14,7 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela
fun toNAddr(): String {
val kind = kind.toByteArray()
val author = pubKeyHex.toByteArray()
val author = pubKeyHex.hexToByteArray()
val dTag = dTag.toByteArray(Charsets.UTF_8)
val relay = relay?.toByteArray(Charsets.UTF_8)

View File

@@ -10,13 +10,9 @@ class BadgeAwardEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
fun awardees() = taggedUsers()
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun awardDefinition() = taggedAddresses()
companion object {
const val kind = 8

View File

@@ -10,13 +10,13 @@ class BadgeDefinitionEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val kind = 30009

View File

@@ -1,7 +1,10 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
open class BaseTextNoteEvent(
id: HexKey,
@@ -13,7 +16,50 @@ open class BaseTextNoteEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
private var citedUsersCache: Set<HexKey>? = null
fun citedUsers(): Set<HexKey> {
citedUsersCache?.let { return it }
val matcher = tagSearch.matcher(content)
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag.size > 1 && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
}
val matcher2 = nip19regex.matcher(content)
while (matcher2.find()) {
val uriScheme = matcher2.group(1) // nostr:
val type = matcher2.group(2) // npub1
val key = matcher2.group(3) // bech32
val additionalChars = matcher2.group(4) // additional chars
try {
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
if (tag != null && tag[0] == "p") {
returningList.add(tag[1])
}
}
} catch (e: Exception) {
Log.w("Unable to parse cited users that matched a NIP19 regex", e)
}
}
citedUsersCache = returningList
return returningList
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
@@ -22,15 +68,40 @@ open class BaseTextNoteEvent(
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
if (tag != null && tag.size > 1 && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
if (tag != null && tag.size > 1 && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
val matcher2 = nip19regex.matcher(content)
while (matcher2.find()) {
val uriScheme = matcher2.group(1) // nostr:
val type = matcher2.group(2) // npub1
val key = matcher2.group(3) // bech32
val additionalChars = matcher2.group(4) // additional chars
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
try {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
}
return citations
}

View File

@@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@@ -15,50 +12,7 @@ class BookmarkListEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30001
@@ -77,24 +31,7 @@ class BookmarkListEvent(
createdAt: Long = Date().time / 1000
): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
val content = Utils.encrypt(
msg,
privateKey,
pubKey
)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))

View File

@@ -12,16 +12,15 @@ class ChannelMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 42
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
@@ -33,6 +32,9 @@ class ChannelMessageEvent(
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
@@ -38,27 +39,54 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1]
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
ATag.parse(aTagValue, relay)
}
override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && 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 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 getReward(): BigDecimal? {
return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
} catch (e: Exception) {
null
}
@@ -83,11 +111,12 @@ open class Event(
}
override fun hasValidSignature(): Boolean {
if (!id.contentEquals(generateId())) {
return false
return try {
id.contentEquals(generateId()) && secp256k1.verifySchnorr(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
}
return secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
}
private fun generateId(): String {
@@ -182,6 +211,8 @@ open class Event(
.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)
@@ -201,11 +232,18 @@ open class Event(
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)

View File

@@ -24,12 +24,15 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
fun isTaggedUser(idHex: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String?
fun isTaggedHashes(hashtags: Set<String>): Boolean
fun firstIsTaggedHashes(hashtags: Set<String>): String?
fun hashtags(): List<String>
fun getReward(): BigDecimal?
fun getPoWRank(): Int
fun zapAddress(): String?
}

View File

@@ -0,0 +1,75 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class FileHeaderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
companion object {
const val kind = 1063
private const val URL = "url"
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
fun create(
url: String,
mimeType: String? = null,
description: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileHeaderEvent {
val tags = listOfNotNull(
listOf(URL, url),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
)
val content = description ?: ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
data class AESGCM(val key: String, val nonce: String)

View File

@@ -0,0 +1,58 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Base64
import java.util.Date
class FileStorageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) }
fun decode(): ByteArray? {
return try {
Base64.getDecoder().decode(content)
} catch (e: Exception) {
Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content")
null
}
}
companion object {
const val kind = 1064
private const val TYPE = "type"
private const val DECRYPT = "decrypt"
fun encode(bytes: ByteArray): String {
return Base64.getEncoder().encodeToString(bytes)
}
fun create(
mimeType: String,
data: ByteArray,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileStorageEvent {
val tags = listOfNotNull(
listOf(TYPE, mimeType)
)
val content = encode(data)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -0,0 +1,73 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class FileStorageHeaderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
companion object {
const val kind = 1065
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
fun create(
storageEvent: FileStorageEvent,
mimeType: String? = null,
description: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileStorageHeaderEvent {
val tags = listOfNotNull(
listOf("e", storageEvent.id),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
)
val content = description ?: ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -0,0 +1,92 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.hexToByteArray
import nostr.postr.Utils
abstract class GeneralListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = taggedEvents()
fun bookmarkedPeople() = taggedUsers()
fun plainContent(privKey: ByteArray): String? {
if (content.isBlank()) return null
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message} for ${dTag()}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
fun createPrivateTags(
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
pubKey: ByteArray
): String {
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
return Utils.encrypt(
msg,
privateKey,
pubKey
)
}
}
}

View File

@@ -0,0 +1,36 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class HighlightEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun inUrl() = taggedUrls().firstOrNull()
fun author() = taggedUsers().firstOrNull()
fun quote() = content
companion object {
const val kind = 9802
fun create(
msg: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}

View File

@@ -1,62 +1,74 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
class LnZapEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
override fun zappedPost() = tags
.filter { it.firstOrNull() == "e" }
.mapNotNull { it.getOrNull(1) }
override fun zappedAuthor() = tags
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun amount(): BigDecimal? {
return amount
}
// Keeps this as a field because it's a heavier function used everywhere.
val amount by lazy {
try {
lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
null
}
}
override fun containedPost(): Event? = try {
description()?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
}
private fun lnInvoice(): String? = tags
.filter { it.firstOrNull() == "bolt11" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
private fun description(): String? = tags
.filter { it.firstOrNull() == "description" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
companion object {
const val kind = 9735
}
}
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
class LnZapEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
// This event is also kept in LocalCache (same object)
@Transient val zapRequest: LnZapRequestEvent?
override fun containedPost(): LnZapRequestEvent? = try {
description()?.ifBlank { null }?.let {
fromJson(it, Client.lenient)
} as? LnZapRequestEvent
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
}
init {
zapRequest = containedPost()
}
override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
override fun zappedPollOption(): Int? = try {
zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt()
} catch (e: Exception) {
Log.e("LnZapEvent", "ZappedPollOption failed to parse", e)
null
}
override fun zappedRequestAuthor(): String? = zapRequest?.pubKey()
override fun amount() = amount
// Keeps this as a field because it's a heavier function used everywhere.
val amount by lazy {
try {
lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
null
}
}
override fun content(): String {
return content
}
fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val kind = 9735
}
enum class ZapType() {
PUBLIC,
PRIVATE,
ANONYMOUS,
NONZAP
}
}

View File

@@ -6,8 +6,12 @@ interface LnZapEventInterface : EventInterface {
fun zappedPost(): List<String>
fun zappedPollOption(): Int?
fun zappedAuthor(): List<String>
fun zappedRequestAuthor(): String?
fun taggedAddresses(): List<ATag>
fun amount(): BigDecimal?

View File

@@ -1,10 +1,15 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.lang.reflect.Type
import java.util.Date
class LnZapPaymentRequestEvent(
@@ -16,11 +21,27 @@ class LnZapPaymentRequestEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun lnInvoice(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
// Once one of an app user decrypts the payment, all users else can see it.
@Transient
private var lnInvoice: String? = null
return Utils.decrypt(content, sharedSecret)
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
if (lnInvoice != null) {
return lnInvoice
}
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
val jsonText = Utils.decrypt(content, sharedSecret)
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
return lnInvoice
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
@@ -37,11 +58,12 @@ class LnZapPaymentRequestEvent(
createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
val content = Utils.encrypt(
lnInvoice,
serializedRequest,
privateKey,
walletServicePubkey.toByteArray()
walletServicePubkey.hexToByteArray()
)
val tags = mutableListOf<List<String>>()
@@ -53,3 +75,40 @@ class LnZapPaymentRequestEvent(
}
}
}
// REQUEST OBJECTS
abstract class Request(var method: String? = null)
// PayInvoice Call
class PayInvoiceParams(var invoice: String? = null)
class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
companion object {
fun create(bolt11: String): PayInvoiceMethod {
return PayInvoiceMethod(PayInvoiceParams(bolt11))
}
}
}
class RequestDeserializer :
JsonDeserializer<Request?> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Request? {
val jsonObject = json.asJsonObject
val method = jsonObject.get("method")?.asString
if (method == "pay_invoice") {
return context.deserialize<PayInvoiceMethod>(jsonObject, PayInvoiceMethod::class.java)
}
return null
}
companion object {
}
}

View File

@@ -0,0 +1,139 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import nostr.postr.Utils
import java.lang.reflect.Type
class LnZapPaymentResponseEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
// Once one of an app user decrypts the payment, all users else can see it.
@Transient
private var response: Response? = null
fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1)
private fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
val retVal = Utils.decrypt(content, sharedSecret)
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
retVal.substring(16)
} else {
retVal
}
} catch (e: Exception) {
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
null
}
}
fun response(privKey: ByteArray, pubKey: ByteArray): Response? {
if (response != null) response
return try {
if (content.isNotEmpty()) {
val decrypted = decrypt(privKey, pubKey)
response = gson.fromJson(decrypted, Response::class.java)
response
} else {
null
}
} catch (e: Exception) {
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
null
}
}
companion object {
const val kind = 23195
}
}
// RESPONSE OBJECTS
abstract class Response(
@SerializedName("result_type")
val resultType: String
)
// PayInvoice Call
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
Response("pay_invoice") {
class PayInvoiceResultParams(val preimage: String)
}
class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
Response("pay_invoice") {
class PayInvoiceErrorParams(val code: ErrorType?, val message: String?)
enum class ErrorType {
@SerializedName(value = "rate_limited", alternate = ["RATE_LIMITED"])
RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds.
@SerializedName(value = "not_implemented", alternate = ["NOT_IMPLEMENTED"])
NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented.
@SerializedName(value = "insufficient_balance", alternate = ["INSUFFICIENT_BALANCE"])
INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount.
@SerializedName(value = "quota_exceeded", alternate = ["QUOTA_EXCEEDED"])
QUOTA_EXCEEDED, // The wallet has exceeded its spending quota.
@SerializedName(value = "restricted", alternate = ["RESTRICTED"])
RESTRICTED, // This public key is not allowed to do this operation.
@SerializedName(value = "unauthorized", alternate = ["UNAUTHORIZED"])
UNAUTHORIZED, // This public key has no wallet connected.
@SerializedName(value = "internal", alternate = ["INTERNAL"])
INTERNAL, // An internal error.
@SerializedName(value = "other", alternate = ["OTHER"])
OTHER // Other error.
}
}
class ResponseDeserializer :
JsonDeserializer<Response?> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Response? {
val jsonObject = json.asJsonObject
val resultType = jsonObject.get("result_type")?.asString
if (resultType == "pay_invoice") {
val result = jsonObject.get("result")?.asJsonObject
val error = jsonObject.get("error")?.asJsonObject
if (result != null) {
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (error != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
} else {
// tries to guess
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
}
return null
}
companion object {
}
}

View File

@@ -1,95 +1,179 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class LnZapRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 9734
fun create(
originalNote: EventInterface,
relays: Set<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
listOf("relays") + relays
)
if (originalNote is LongTextNoteEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(
userHex: String,
relays: Set<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"content": "",
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
"created_at": 1674164539,
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
"kind": 9734,
"tags": [
[
"e",
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
],
[
"p",
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
],
[
"relays",
"wss://relay.damus.io",
"wss://nostr-relay.wlvs.space",
"wss://nostr.fmt.wiz.biz",
"wss://relay.nostr.bg",
"wss://nostr.oxtr.dev",
"wss://nostr.v0l.io",
"wss://brb.io",
"wss://nostr.bitcoiner.social",
"ws://monad.jb55.com:8080",
"wss://relay.snort.social"
]
]
}
*/
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.*
import nostr.postr.Bech32
import nostr.postr.Utils
import java.nio.charset.Charset
import java.security.SecureRandom
import java.util.*
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
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 = Date().time / 1000
): LnZapRequestEvent {
var content = message
var privkey = privateKey
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
listOf("relays") + relays
)
if (originalNote is LongTextNoteEvent) {
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 = Utils.privkeyCreate()
pubKey = Utils.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 = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey() // updated event with according pubkey
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.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 = Date().time / 1000
): LnZapRequestEvent {
var content = message
var privkey = privateKey
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
privkey = Utils.privkeyCreate()
pubKey = Utils.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 = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey()
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.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 sha256.digest(strbyte)
}
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = Utils.getSharedSecret(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 = Utils.getSharedSecret(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

@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@@ -21,7 +21,7 @@ class MuteListEvent(
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {

View File

@@ -0,0 +1,54 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class PeopleListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30000
fun create(
name: String = "",
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PeopleListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
events?.forEach {
tags.add(listOf("e", it))
}
users?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -0,0 +1,105 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
const val POLL_OPTION = "poll_option"
const val VALUE_MAXIMUM = "value_maximum"
const val VALUE_MINIMUM = "value_minimum"
const val CONSENSUS_THRESHOLD = "consensus_threshold"
const val CLOSED_AT = "closed_at"
class PollNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
// ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun pollOptions() =
tags.filter { it.size > 2 && it[0] == POLL_OPTION }
.associate { it[1].toInt() to it[2] }
fun getTagInt(property: String): Int? {
val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
return if (number.isNullOrBlank() || number == "null") {
null
} else {
number.toInt()
}
}
companion object {
const val kind = 6969
fun create(
msg: String,
replyTos: List<String>?,
mentions: List<String>?,
addresses: List<ATag>?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
pollOptions: Map<Int, String>,
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String?
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
pollOptions.forEach { poll_op ->
tags.add(listOf(POLL_OPTION, poll_op.key.toString(), poll_op.value))
}
tags.add(listOf(VALUE_MAXIMUM, valueMaximum.toString()))
tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
tags.add(listOf(CLOSED_AT, closedAt.toString()))
if (zapReceiver != null) {
tags.add(listOf("zap", zapReceiver))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
/*
{
"id": <32-bytes lowercase hex-encoded sha256 of the serialized event data>
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
"created_at": <unix timestamp in seconds>,
"kind": 6969,
"tags": [
["e", <32-bytes hex of the id of the poll event>, <primary poll host relay URL>],
["p", <32-bytes hex of the key>, <primary poll host relay URL>],
["poll_option", "0", "poll option 0 description string"],
["poll_option", "1", "poll option 1 description string"],
["poll_option", "n", "poll option <n> description string"],
["value_maximum", "maximum satoshi value for inclusion in tally"],
["value_minimum", "minimum satoshi value for inclusion in tally"],
["consensus_threshold", "required percentage to attain consensus <0..100>"],
["closed_at", "unix timestamp in seconds"],
],
"ots": <base64-encoded OTS file data>
"content": <primary poll description string>,
"sig": <64-bytes hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field>
}
*/

View File

@@ -21,7 +21,11 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
@@ -29,7 +33,12 @@ class PrivateDmEvent(
* Nip-18 messages should refer to other events by inline references in the content like
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
*/
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun with(pubkeyHex: String): Boolean {
return pubkeyHex == pubKey ||
tags.firstOrNull { it.size > 1 && it[0] == "p" }?.getOrNull(1) == pubkeyHex
}
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
@@ -58,6 +67,7 @@ class PrivateDmEvent(
msg: String,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
@@ -79,6 +89,9 @@ class PrivateDmEvent(
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())

View File

@@ -14,8 +14,8 @@ class ReactionEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
companion object {
const val kind = 7

View File

@@ -0,0 +1,34 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
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 = Date().time / 1000): RelayAuthEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
listOf("relay", relay),
listOf("challenge", challenge)
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -30,7 +30,7 @@ class ReportEvent(
}
fun reportedPost() = tags
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
.filter { it.size > 1 && it[0] == "e" }
.map {
ReportedKey(
it[1],
@@ -39,7 +39,7 @@ class ReportEvent(
}
fun reportedAuthor() = tags
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
.filter { it.size > 1 && it[0] == "p" }
.map {
ReportedKey(
it[1],

View File

@@ -26,6 +26,7 @@ class TextNoteEvent(
mentions: List<String>?,
addresses: List<ATag>?,
extraTags: List<String>?,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
@@ -46,9 +47,13 @@ class TextNoteEvent(
extraTags?.forEach {
tags.add(listOf("t", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
findURLs(msg).forEach {
tags.add(listOf("r", it))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())

View File

@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Bech32
import nostr.postr.bechToBytes
import nostr.postr.toByteArray
import java.util.regex.Pattern
object Nip19 {
@@ -10,21 +13,46 @@ object Nip19 {
USER, NOTE, EVENT, RELAY, ADDRESS
}
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "")
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)
matcher.find()
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
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)
@@ -35,12 +63,11 @@ object Nip19 {
"naddr1" -> naddr(bytes)
else -> null
}
return parsed?.copy(additionalChars = additionalChars)
parsed?.copy(additionalChars = additionalChars ?: "")
} catch (e: Throwable) {
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
null
}
return null
}
private fun npub(bytes: ByteArray): Return {
@@ -76,7 +103,15 @@ object Nip19 {
?.get(0)
?.toString(Charsets.UTF_8)
return Return(Type.EVENT, hex, relay)
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? {
@@ -107,6 +142,29 @@ object Nip19 {
?.get(0)
?.let { Tlv.toInt32(it) }
return Return(Type.ADDRESS, "$kind:$author:$d", relay)
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 Bech32.encodeBytes(hrp = "nevent", fullArray, Bech32.Encoding.Bech32)
}
}

View File

@@ -0,0 +1,99 @@
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
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.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
// adds to database
LocalCache.consume(event, null)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
}
}
private fun notify(event: PrivateDmEvent) {
val note = LocalCache.notes[event.id] ?: return
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
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()
if (note.author in messagingWith) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(content, user, userPicture, noteUri, applicationContext)
}
}
}
}
private fun notify(event: LnZapEvent) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
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)
}
}
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(content, title, userPicture, noteUri, applicationContext)
}
}
}
private fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
}
}

View File

@@ -0,0 +1,185 @@
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import androidx.core.app.NotificationCompat
import coil.ImageLoader
import coil.executeBlocking
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
object NotificationUtils {
// Notification ID.
private var notificationId = 0
private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
dmChannel = NotificationChannel(
applicationContext.getString(R.string.app_notification_dms_channel_id),
applicationContext.getString(R.string.app_notification_dms_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = applicationContext.getString(R.string.app_notification_dms_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(dmChannel!!)
return dmChannel!!
}
private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
if (zapChannel != null) return zapChannel!!
zapChannel = NotificationChannel(
applicationContext.getString(R.string.app_notification_zaps_channel_id),
applicationContext.getString(R.string.app_notification_zaps_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = applicationContext.getString(R.string.app_notification_zaps_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(zapChannel!!)
return zapChannel!!
}
fun NotificationManager.sendZapNotification(
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
applicationContext: Context
) {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
}
fun NotificationManager.sendDMNotification(
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
applicationContext: Context
) {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
}
fun NotificationManager.sendNotification(
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
channelId: String,
applicationContext: Context
) {
if (pictureUrl != null) {
val request = ImageRequest.Builder(applicationContext)
.data(pictureUrl)
.build()
val imageLoader = ImageLoader(applicationContext)
val imageResult = imageLoader.executeBlocking(request)
sendNotification(
messageBody = messageBody,
messageTitle = messageTitle,
picture = imageResult.drawable as? BitmapDrawable,
uri = uri,
channelId,
applicationContext = applicationContext
)
} else {
sendNotification(
messageBody = messageBody,
messageTitle = messageTitle,
picture = null,
uri = uri,
channelId,
applicationContext = applicationContext
)
}
}
private fun NotificationManager.sendNotification(
messageBody: String,
messageTitle: String,
picture: BitmapDrawable?,
uri: String,
channelId: String,
applicationContext: Context
) {
val contentIntent = Intent(applicationContext, MainActivity::class.java).apply {
data = Uri.parse(uri)
}
val contentPendingIntent = PendingIntent.getActivity(
applicationContext,
notificationId,
contentIntent,
PendingIntent.FLAG_MUTABLE
)
// Build the notification
val builderPublic = NotificationCompat.Builder(
applicationContext,
channelId
)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
.setGroup(messageTitle)
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
// Build the notification
val builder = NotificationCompat.Builder(
applicationContext,
channelId
)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
.setGroup(messageTitle)
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
notify(notificationId, builder.build())
notificationId++
}
/**
* Cancels all notifications.
*/
fun NotificationManager.cancelNotifications() {
cancelAll()
}
}

View File

@@ -0,0 +1,73 @@
package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class RegisterAccounts(
private val accounts: List<AccountInfo>
) {
// creates proof that it controls all accounts
private fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String
): List<RelayAuthEvent> {
return accounts.mapNotNull {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null) {
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
if (relayToUse != null) {
acc.createAuthEvent(relayToUse, notificationToken)
} else {
null
}
} else {
null
}
}
}
private fun postRegistrationEvent(events: List<RelayAuthEvent>) {
try {
val jsonObject = """{
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
}
"""
val mediaType = "application/json; charset=utf-8".toMediaType()
val body = jsonObject.toRequestBody(mediaType)
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://push.amethyst.social/register")
.post(body)
.build()
val client = HttpClient.getHttpClient()
client.newCall(request).execute()
} catch (e: java.lang.Exception) {
Log.e("FirebaseMsgService", "Unable to register with push server", e)
}
}
fun go(notificationToken: String) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
}
}

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
@@ -64,21 +66,49 @@ object Client : RelayPool.Listener {
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: EventInterface, relay: String? = null) {
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelay = relays.filter { it.url == relay }
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
if (useConnectedRelay.isNotEmpty()) {
useConnectedRelay.forEach {
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach {
it.send(signedEvent)
}
} else {
/** temporary connection */
Relay(relay, false, true, emptySet()).requestAndWatch() {
it.send(signedEvent)
it.disconnect()
newSporadicRelay(
relay,
feedTypes,
onConnected = { relay ->
relay.send(signedEvent)
},
onDone = onDone
)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
RelayPool.addRelay(relay)
relay.requestAndWatch {
allSubscriptions().forEach {
relay.sendFilter(requestId = it)
}
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(10000) // waits for a reply
relay.disconnect()
RelayPool.removeRelay(relay)
if (onDone != null) {
onDone()
}
}
}
@@ -131,6 +161,14 @@ object Client : RelayPool.Listener {
}
}
override fun onAuth(relay: Relay, challenge: String) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onAuth(relay, challenge) }
}
}
fun subscribe(listener: Listener) {
listeners = listeners.plus(listener)
}
@@ -167,5 +205,7 @@ object Client : RelayPool.Listener {
* When an relay saves or rejects a new event.
*/
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
open fun onAuth(relay: Relay, challenge: String) = Unit
}
}

View File

@@ -1,29 +1,33 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.HttpClient
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS)
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
val activeTypesSearch = setOf(FeedType.SEARCH)
fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy())
}.toTypedArray()
}
val defaultRelays = arrayOf(
// Free relays
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
// 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),
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
// Chats
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats),
// Less Reliable
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),

View File

@@ -0,0 +1,46 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.User
class EOSETime(var time: Long) {
override fun toString(): String {
return time.toString()
}
}
class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
fun addOrUpdate(relayUrl: String, time: Long) {
val eose = relayList[relayUrl]
if (eose == null) {
relayList = relayList + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
fun addOrUpdate(listCode: String, relayUrl: String, time: Long) {
val relayList = followList[listCode]
if (relayList == null) {
val newList = EOSERelayList()
newList.addOrUpdate(relayUrl, time)
followList = followList + mapOf(listCode to newList)
} else {
relayList.addOrUpdate(relayUrl, time)
}
}
}
class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) {
fun addOrUpdate(user: User, listCode: String, relayUrl: String, time: Long) {
val followList = users[user]
if (followList == null) {
val newList = EOSEFollowList()
newList.addOrUpdate(listCode, relayUrl, time)
users = users + mapOf(user to newList)
} else {
followList.addOrUpdate(listCode, relayUrl, time)
}
}
}

View File

@@ -4,14 +4,13 @@ import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import java.util.*
class JsonFilter(
val ids: List<String>? = null,
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null,
val since: Map<String, Long>? = null,
val since: Map<String, EOSETime>? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null
@@ -37,7 +36,7 @@ class JsonFilter(
if (forRelay != null) {
val relaySince = get(forRelay)
if (relaySince != null) {
jsonObject.addProperty("since", relaySince)
jsonObject.addProperty("since", relaySince.time)
}
} else {
val jsonObjectSince = JsonObject()

View File

@@ -5,24 +5,36 @@ import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.net.Proxy
import java.time.Duration
import java.util.Date
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
}
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
class Relay(
var url: String,
var read: Boolean = true,
var write: Boolean = true,
var activeTypes: Set<FeedType> = FeedType.values().toSet()
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
proxy: Proxy?
) {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
private val httpClient = OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.followRedirects(true)
.followSslRedirects(true)
.build()
@@ -40,6 +52,8 @@ class Relay(
var closingTime = 0L
var afterEOSE = false
fun register(listener: Listener) {
listeners = listeners.plus(listener)
}
@@ -74,6 +88,7 @@ class Relay(
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
afterEOSE = false
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
@@ -83,35 +98,42 @@ class Relay(
}
override fun onMessage(webSocket: WebSocket, text: String) {
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
val type = msg[0].asString
val channel = msg[1].asString
when (type) {
"EVENT" -> {
// Log.w("Relay", "Relay onEVENT $url, $channel")
eventDownloadCounterInBytes += text.bytesUsedInMemory()
val event = Event.fromJson(msg[2], Client.lenient)
if (event.kind == 23195 || event.kind == 23196) {
println("AAAAA ${event.toJson()}")
// Log.w("Relay", "Relay onEVENT $url, $channel")
listeners.forEach {
it.onEvent(this@Relay, channel, event)
if (afterEOSE) {
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
}
listeners.forEach { it.onEvent(this@Relay, channel, event) }
}
"EOSE" -> listeners.forEach {
afterEOSE = true
// Log.w("Relay", "Relay onEOSE $url, $channel")
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
"NOTICE" -> listeners.forEach {
// Log.w("Relay", "Relay onNotice $url, $channel")
// "channel" being the second string in the string array ...
Log.w("Relay", "Relay onNotice $url, $channel")
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
// Log.w("Relay", "Relay onOK $url, $channel")
Log.w("Relay", "Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}")
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
"AUTH" -> listeners.forEach {
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
it.onAuth(this@Relay, msg[1].asString)
}
else -> listeners.forEach {
// Log.w("Relay", "Relay something else $url, $channel")
it.onError(
@@ -142,6 +164,7 @@ class Relay(
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
socket = null
isReady = false
afterEOSE = false
closingTime = Date().time / 1000
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
}
@@ -153,6 +176,7 @@ class Relay(
// Failures disconnect the relay.
socket = null
isReady = false
afterEOSE = false
closingTime = Date().time / 1000
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
@@ -167,6 +191,7 @@ class Relay(
} catch (e: Exception) {
errorCounter++
isReady = false
afterEOSE = false
closingTime = Date().time / 1000
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
@@ -179,6 +204,7 @@ class Relay(
socket?.close(1000, "Normal close")
socket = null
isReady = false
afterEOSE = false
}
fun sendFilter(requestId: String) {
@@ -192,6 +218,7 @@ class Relay(
// println("FILTERSSENT $url $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
afterEOSE = false
}
}
} else {
@@ -215,11 +242,19 @@ class Relay(
}
fun send(signedEvent: EventInterface) {
if (write) {
val event = """["EVENT",${signedEvent.toJson()}]"""
if (signedEvent is RelayAuthEvent) {
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
if (write) {
if (signedEvent !is RelayAuthEvent) {
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
}
}
fun close(subscriptionId: String) {
@@ -257,6 +292,8 @@ class Relay(
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
fun onAuth(relay: Relay, challenge: String)
/**
* Connected to or disconnected from a relay
*

View File

@@ -93,6 +93,8 @@ object RelayPool : Relay.Listener {
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
fun onAuth(relay: Relay, challenge: String)
}
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
@@ -113,6 +115,10 @@ object RelayPool : Relay.Listener {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
override fun onAuth(relay: Relay, challenge: String) {
listeners.forEach { it.onAuth(relay, challenge) }
}
// Observers line up here.
val live: RelayPoolLiveData = RelayPoolLiveData(this)

View File

@@ -19,8 +19,17 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
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.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.muted
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
@@ -28,20 +37,48 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val nip19 = Nip19.uriToRoute(intent?.data?.toString())
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
val uri = intent?.data?.toString()
val startingPage = if (uri.equals("nostr:Notifications", true)) {
Route.Notification.route.replace("{scrollToTop}", "true")
} else {
val nip19 = Nip19.uriToRoute(uri)
when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> {
if (nip19.kind == PrivateDmEvent.kind) {
"Room/${nip19.author}"
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
"Channel/${nip19.hex}"
} else {
"Event/${nip19.hex}"
}
}
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
} ?: try {
uri?.let {
Nip47.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}
} catch (e: Exception) {
null
}
// Initializes video cache.
VideoCache.init(this.applicationContext)
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {
@@ -76,11 +113,15 @@ class MainActivity : FragmentActivity() {
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
// starts muted every time
muted.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start()
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
}
override fun onPause() {

View File

@@ -0,0 +1,47 @@
package com.vitorpamplona.amethyst.ui.actions
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
import java.net.URL
class ImageDownloader {
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
var imageData: ByteArray? = null
var tentatives = 0
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) {
imageData = try {
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc = url.openConnection() as HttpURLConnection
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again
url = URL(newUrl)
huc = url.openConnection() as HttpURLConnection
responseCode = huc.responseCode
}
if (responseCode in 200..300) {
huc.inputStream.use { it.readBytes() }
} else {
tentatives++
delay(1000)
null
}
} catch (e: Exception) {
tentatives++
delay(1000)
null
}
}
return imageData
}
}

View File

@@ -7,13 +7,18 @@ import android.media.MediaScannerConnection
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.buffer
import okio.sink
import okio.source
import java.io.File
import java.util.UUID
object ImageSaver {
/**
@@ -28,7 +33,7 @@ object ImageSaver {
onSuccess: () -> Any?,
onError: (Throwable) -> Any?
) {
val client = OkHttpClient.Builder().build()
val client = HttpClient.getHttpClient()
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
@@ -74,6 +79,38 @@ object ImageSaver {
})
}
fun saveImage(
localFile: File,
mimeType: String?,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?
) {
try {
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val buffer = localFile.inputStream().source().buffer()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveContentQ(
displayName = UUID.randomUUID().toString(),
contentType = mimeType ?: "",
contentSource = buffer,
contentResolver = context.contentResolver
)
} else {
saveContentDefault(
fileName = UUID.randomUUID().toString() + ".$extension",
contentSource = buffer,
context = context
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveContentQ(
displayName: String,
@@ -90,8 +127,14 @@ object ImageSaver {
)
}
val masterUri = if (contentType.startsWith("image")) {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
contentResolver.insert(masterUri, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}
@@ -138,5 +181,5 @@ object ImageSaver {
MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst " + BuildConfig.VERSION_NAME
private const val PICTURES_SUBDIRECTORY = "Amethyst"
}

View File

@@ -2,69 +2,118 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okio.BufferedSink
import okio.source
import java.io.IOException
import java.util.*
import java.io.InputStream
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
object ImageUploader {
fun uploadImage(
uri: Uri,
server: ServersAvailable,
contentResolver: ContentResolver,
onSuccess: (String) -> Unit,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
val imageInputStream = contentResolver.openInputStream(uri)
val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
val length = contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: 0
val client = OkHttpClient.Builder().build()
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
val myServer = when (server) {
ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
ImgurServer()
}
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
NostrImgServer()
}
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
NostrBuildServer()
}
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
NostrFilesDevServer()
}
else -> {
ImgurServer()
}
}
val requestBody: RequestBody = MultipartBody.Builder()
uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError)
}
fun uploadImage(
inputStream: InputStream,
length: Long,
contentType: String?,
server: FileServer,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
category,
"${UUID.randomUUID()}",
server.inputParameterName(contentType),
"$fileName.$extension",
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
imageInputStream.source().use(sink::writeAll)
inputStream.source().use(sink::writeAll)
}
}
)
.build()
val request: Request = Request.Builder()
.header("Authorization", "Client-ID e6aea87296f3f96")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
server.clientID()?.let {
requestBuilder.addHeader("Authorization", it)
}
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.postUrl(contentType))
.post(requestBody)
.build()
val request = requestBuilder.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
response.body.use { body ->
val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText()
val url = server.parseUrlFromSuccess(body.string())
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
onSuccess(url)
onSuccess(url, contentType)
}
} catch (e: Exception) {
e.printStackTrace()
@@ -79,3 +128,74 @@ object ImageUploader {
})
}
}
abstract class FileServer {
abstract fun postUrl(contentType: String?): String
abstract fun parseUrlFromSuccess(body: String): String?
abstract fun inputParameterName(contentType: String?): String
open fun clientID(): String? = null
}
class NostrImgServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = null
}
class ImgurServer : FileServer() {
override fun postUrl(contentType: String?): String {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
}
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = "Client-ID e6aea87296f3f96"
}
class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
override fun parseUrlFromSuccess(body: String): String? {
val url = jacksonObjectMapper().readTree(body) // return url.toString()
return url.toString().replace("\"", "")
}
override fun inputParameterName(contentType: String?): String {
return "fileToUpload"
}
override fun clientID() = null
}
class NostrFilesDevServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
return tree?.get("url")?.asText()
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
override fun clientID() = null
}

View File

@@ -0,0 +1,350 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) {
val searchBarViewModel: SearchBarViewModel = viewModel()
Dialog(
onDismissRequest = {
NostrSearchEventOrUserDataSource.clear()
searchBarViewModel.clean()
onClose()
},
properties = DialogProperties(
dismissOnClickOutside = false
)
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
searchBarViewModel.clean()
NostrSearchEventOrUserDataSource.clear()
onClose()
})
Text(
text = stringResource(R.string.channel_list_join_conversation),
fontWeight = FontWeight.Bold
)
Text(
text = "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(15.dp))
RenderSeach(searchBarViewModel, account, navController)
}
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun RenderSeach(
searchBarViewModel: SearchBarViewModel,
account: Account,
navController: NavController
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val onlineSearch = NostrSearchEventOrUserDataSource
val lifeCycleOwner = LocalLifecycleOwner.current
// Create a channel for processing search queries.
val searchTextChanges = remember {
Channel<String>(Channel.CONFLATED)
}
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
if (searchBarViewModel.isSearching()) {
searchBarViewModel.invalidateData()
}
}
}
}
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
// Wait for text changes to stop for 300 ms before firing off search.
withContext(Dispatchers.IO) {
searchTextChanges.receiveAsFlow()
.filter { it.isNotBlank() }
.distinctUntilChanged()
.debounce(300)
.collectLatest {
if (it.length >= 2) {
onlineSearch.search(it.trim())
}
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) {
listState.animateScrollToItem(0)
}
}
}
}
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Join Start")
NostrSearchEventOrUserDataSource.start()
searchBarViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Join Stop")
NostrSearchEventOrUserDataSource.clear()
NostrSearchEventOrUserDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
// LAST ROW
Row(
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) },
value = searchBarViewModel.searchValue,
onValueChange = {
searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) {
searchTextChanges.trySend(it)
}
},
leadingIcon = {
Icon(
painter = painterResource(R.drawable.ic_search),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
},
modifier = Modifier
.weight(1f, true)
.defaultMinSize(minHeight = 20.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.channel_list_user_or_group_id_demo),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
if (searchBarViewModel.isTrailingIconVisible) {
IconButton(
onClick = {
searchBarViewModel.clean()
onlineSearch.clear()
}
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(R.string.clear)
)
}
}
}
)
}
if (searchBarViewModel.searchValue.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp)
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(
searchBarViewModel.searchResults.value,
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(
item,
account = account,
navController = navController
)
}
itemsIndexed(
searchBarViewModel.searchResultsChannels.value,
key = { _, item -> "c" + item.idHex }
) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { navController.navigate("Channel/${item.idHex}") }
)
}
}
}
}
}
@Composable
fun UserComposeForChat(
baseUser: User,
account: Account,
navController: NavController
) {
Column(
modifier =
Modifier.clickable(
onClick = { navController.navigate("Room/${baseUser.pubkeyHex}") }
)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseUser)
}
val baseUserState by baseUser.live().metadata.observeAsState()
val user = baseUserState?.user ?: return
Text(
user.info?.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}

View File

@@ -0,0 +1,208 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
open class NewMediaModel : ViewModel() {
var account: Account? = null
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var description by mutableStateOf("")
// Images and Videos
var galleryUri by mutableStateOf<Uri?>(null)
var uploadingPercentage = mutableStateOf(0.0f)
var uploadingDescription = mutableStateOf<String?>(null)
var onceUploaded: () -> Unit = {}
open fun load(account: Account, uri: Uri, contentType: String?) {
this.account = account
this.galleryUri = uri
this.mediaType = contentType
this.selectedServer = defaultServer()
if (selectedServer == ServersAvailable.IMGUR) {
selectedServer = ServersAvailable.IMGUR_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
}
}
fun upload(context: Context) {
isUploadingImage = true
val contentResolver = context.contentResolver
val uri = galleryUri ?: return
val serverToUse = selectedServer ?: return
if (selectedServer == ServersAvailable.NIP95) {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Loading"
val contentType = contentResolver.getType(uri)
contentResolver.openInputStream(uri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
?: run {
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}
}
} else {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage(
uri = uri,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
open fun cancel() {
galleryUri = null
isUploadingImage = false
mediaType = null
uploadingDescription.value = null
uploadingPercentage.value = 0.0f
description = ""
selectedServer = account?.defaultFileServer
}
fun canPost(): Boolean {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
if (imageData != null) {
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
FileHeader.prepare(
imageData,
imageUrl,
mimeType,
description,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
account?.sendHeader(it)
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
uploadingPercentage.value = 0.20f
uploadingDescription.value = "Hashing"
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
description,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.30f
val nip95 = account?.createNip95(bytes, headerInfo = it)
if (nip95 != null) {
uploadingDescription.value = "Sending"
uploadingPercentage.value = 0.60f
account?.sendNip95(nip95.first, nip95.second)
}
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
},
onError = {
uploadingDescription.value = null
uploadingPercentage.value = 0.00f
isUploadingImage = false
cancel()
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
fun isImage() = mediaType?.startsWith("image")
fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.defaultFileServer
fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded
}
}

View File

@@ -0,0 +1,230 @@
package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
val account = accountViewModel.accountLiveData.value?.account ?: return
val resolver = LocalContext.current.contentResolver
val context = LocalContext.current
val scroolState = rememberScrollState()
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.fillMaxWidth()
.fillMaxHeight().imePadding()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
PostButton(
onPost = {
onClose()
postViewModel.upload(context)
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
},
isActive = postViewModel.canPost()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scroolState)
) {
ImageVideoPost(postViewModel, account)
}
}
}
}
}
}
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope()
val fileServers = listOf(
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
val resolver = LocalContext.current.contentResolver
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
if (postViewModel.isImage() == true) {
AsyncImage(
model = postViewModel.galleryUri.toString(),
contentDescription = postViewModel.galleryUri.toString(),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
)
} else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(key1 = postViewModel.galleryUri) {
scope.launch(Dispatchers.IO) {
postViewModel.galleryUri?.let {
try {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
} catch (e: Exception) {
postViewModel.imageUploadingError.emit("Unable to load file")
}
}
}
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "some useful description",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
)
}
} else {
postViewModel.galleryUri?.let {
VideoView(it)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {
postViewModel.selectedServer = fileServers[it].first
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
}
if (isNIP94Server(postViewModel.selectedServer) ||
postViewModel.selectedServer == ServersAvailable.NIP95
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = postViewModel.description,
onValueChange = { postViewModel.description = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
)
}
}
}

View File

@@ -0,0 +1,81 @@
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
import com.vitorpamplona.amethyst.service.nip19.Nip19
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun run() {
// adds all references to mentions and reply tos
message.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
addNoteToReplyTos(note)
}
}
}
}
// Tags the text in the correct order.
message = message.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"nostr:${note.toNEvent()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"nostr:${note.toNEvent()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
"nostr:${note.idNote()}${results.restOfWord}"
} else {
word
}
} else {
word
}
}.joinToString(" ")
}.joinToString("\n")
}
}

View File

@@ -0,0 +1,79 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
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
import androidx.compose.ui.res.stringResource
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.actions.NewPostViewModel
@Composable
fun NewPollClosing(pollViewModel: NewPostViewModel) {
var text by rememberSaveable { mutableStateOf("") }
pollViewModel.isValidClosedAt.value = true
if (text.isNotEmpty()) {
try {
val int = text.toInt()
if (int < 0) {
pollViewModel.isValidClosedAt.value = false
} else { pollViewModel.closedAt = int }
} catch (e: Exception) { pollViewModel.isValidClosedAt.value = false }
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_closing_time),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_closing_time_days),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
}
}
@Preview
@Composable
fun NewPollClosingPreview() {
NewPollClosing(NewPostViewModel())
}

View File

@@ -0,0 +1,79 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
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
import androidx.compose.ui.res.stringResource
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.actions.NewPostViewModel
@Composable
fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
var text by rememberSaveable { mutableStateOf("") }
pollViewModel.isValidConsensusThreshold.value = true
if (text.isNotEmpty()) {
try {
val int = text.toInt()
if (int < 0 || int > 100) {
pollViewModel.isValidConsensusThreshold.value = false
} else { pollViewModel.consensusThreshold = int }
} catch (e: Exception) { pollViewModel.isValidConsensusThreshold.value = false }
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_consensus_threshold),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_consensus_threshold_percent),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
}
}
@Preview
@Composable
fun NewPollConsensusThresholdPreview() {
NewPollConsensusThreshold(NewPostViewModel())
}

View File

@@ -0,0 +1,60 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
@Composable
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
onClick = {
pollViewModel.pollOptions.remove(optionIndex)
}
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.clear)
)
}
}
OutlinedTextField(
modifier = Modifier.weight(1F),
value = pollViewModel.pollOptions[optionIndex] ?: "",
onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
label = {
Text(
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_option_description),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
// colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else colorInValid,
trailingIcon = if (optionIndex > 1) deleteIcon else null
)
}
}
@Preview
@Composable
fun NewPollOptionPreview() {
NewPollOption(NewPostViewModel(), 0)
}

View File

@@ -0,0 +1,77 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
var isInputValid = true
if (pollViewModel.message.text.isEmpty()) {
isInputValid = false
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
OutlinedTextField(
value = pollViewModel.message,
onValueChange = {
pollViewModel.updateMessage(it)
},
label = {
Text(
text = stringResource(R.string.poll_primary_description),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.poll_primary_description),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
colors = if (isInputValid) colorValid else colorInValid,
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}

View File

@@ -0,0 +1,43 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
@Composable
fun NewPollRecipientsField(pollViewModel: NewPostViewModel, account: Account) {
// if no recipients, add user's pubkey
if (pollViewModel.zapRecipients.isEmpty()) {
pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex)
}
// TODO allow add multiple recipients and check input validity
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(),
value = pollViewModel.zapRecipients[0],
onValueChange = { /* TODO */ },
enabled = false, // TODO enable add recipients
label = {
Text(
text = stringResource(R.string.poll_zap_recipients),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_zap_recipients),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
}

View File

@@ -0,0 +1,197 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import kotlinx.coroutines.delay
@Composable
fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
val pollViewModel: NewPostViewModel = viewModel()
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
pollViewModel.load(account, baseReplyTo, quote)
delay(100)
pollViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.imePadding()
.weight(1f)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
pollViewModel.cancel()
onClose()
})
PollButton(
onPost = {
pollViewModel.sendPost()
onClose()
},
isActive = pollViewModel.message.text.isNotBlank() &&
pollViewModel.pollOptions.values.all { it.isNotEmpty() } &&
pollViewModel.isValidRecipients.value &&
pollViewModel.isValidvalueMaximum.value &&
pollViewModel.isValidvalueMinimum.value &&
pollViewModel.isValidConsensusThreshold.value &&
pollViewModel.isValidClosedAt.value
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
) {
if (pollViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(pollViewModel.replyTos, pollViewModel.mentions, account, "") {
pollViewModel.removeFromReplyList(it)
}
}
Text(stringResource(R.string.poll_heading_required))
// NewPollRecipientsField(pollViewModel, account)
NewPollPrimaryDescription(pollViewModel)
pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(pollViewModel, index)
}
Button(
onClick = { pollViewModel.pollOptions[pollViewModel.pollOptions.size] = "" },
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Image(
painterResource(id = android.R.drawable.ic_input_add),
contentDescription = "Add poll option button",
modifier = Modifier.size(18.dp)
)
}
Text(stringResource(R.string.poll_heading_optional))
NewPollVoteValueRange(pollViewModel)
NewPollConsensusThreshold(pollViewModel)
NewPollClosing(pollViewModel)
}
}
val userSuggestions = pollViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
modifier = Modifier.heightIn(0.dp, 300.dp)
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
) { _, item ->
UserLine(item, account) {
pollViewModel.autocompleteWithUser(item)
}
}
}
}
Row(modifier = Modifier.fillMaxWidth()) {
/*UploadFromGallery(
isUploading = pollViewModel.isUploadingImage
) {
pollViewModel.upload(it, context)
}*/
}
}
}
}
}
}
@Composable
fun PollButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = stringResource(R.string.post_poll), color = Color.White)
}
}
/*@Preview
@Composable
fun NewPollViewPreview() {
NewPollView(onClose = {}, account = Account(loggedIn = Persona()))
}*/

View File

@@ -0,0 +1,126 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
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
import androidx.compose.ui.res.stringResource
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
@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
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = textMin,
onValueChange = { textMin = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_zap_value_min),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
OutlinedTextField(
value = textMax,
onValueChange = { textMax = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_zap_value_max),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
}
}
@Preview
@Composable
fun NewPollVoteValueRangePreview() {
NewPollVoteValueRange(NewPostViewModel())
}

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