Compare commits

...

1086 Commits

Author SHA1 Message Date
Vitor Pamplona
d5a98db67c v0.90.6 2024-09-02 10:05:07 -04:00
Vitor Pamplona
72412b9d03 Merge 2024-09-02 10:04:38 -04:00
Vitor Pamplona
b5b90c80e9 Revert "v0.90.6"
This reverts commit 2ca5186aef.
2024-09-02 10:03:15 -04:00
Vitor Pamplona
c1077f689d Merge pull request #1046 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-02 10:02:32 -04:00
Crowdin Bot
8b26a84564 New Crowdin translations by GitHub Action 2024-09-02 14:00:35 +00:00
Vitor Pamplona
2ca5186aef v0.90.6 2024-09-02 10:00:28 -04:00
Vitor Pamplona
064e211154 Merge pull request #1045 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-02 09:59:23 -04:00
Vitor Pamplona
2e8ab35f05 Merge pull request #1042 from greenart7c3/clickable_url
Long press to copy relay url
2024-09-02 09:59:14 -04:00
Crowdin Bot
df429b976f New Crowdin translations by GitHub Action 2024-09-02 13:59:11 +00:00
Vitor Pamplona
d40995c338 Merge pull request #1039 from davotoula/1038-Profile-Lightning-payment-is-100x-too-large
millisats were incorrectly multiplied by 1000
2024-09-02 09:57:45 -04:00
greenart7c3
bba7c257da long press to copy relay url in the relay icons and relay row 2024-09-02 09:52:39 -03:00
David Kaspar
a4a753e7b6 Rename amount to amountInMillisats to give visual clue to calling classes that millisats should be used 2024-09-02 12:26:26 +02:00
greenart7c3
53acbd8606 Long press to copy relay url 2024-09-02 07:15:59 -03:00
Vitor Pamplona
f89c1ff391 v0.90.5 2024-09-01 15:07:07 -04:00
Vitor Pamplona
471ee21749 Merge pull request #1041 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-01 12:38:26 -04:00
Crowdin Bot
24f7e043af New Crowdin translations by GitHub Action 2024-09-01 16:37:46 +00:00
Vitor Pamplona
34e82dab8b Merge pull request #1040 from davotoula/1037-tor-setting-reset-to-off-after-switching-away
1037 tor setting reset to off after switching away
2024-09-01 12:36:36 -04:00
David Kaspar
9fa1b8c541 Close "needsPassword.value" if statement earlier to make Orbot checkbox visible again 2024-09-01 17:09:03 +02:00
David Kaspar
1e61162f73 change the logic for saving of proxy state 2024-09-01 17:05:20 +02:00
David Kaspar
941266365e millisats were incorrectly multiplied by 1000 2024-08-31 19:24:12 +01:00
Vitor Pamplona
b1f1466e6b v0.90.4 2024-08-30 18:43:34 -04:00
Vitor Pamplona
55d53dbc4f Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-30 18:42:50 -04:00
Vitor Pamplona
aeaddf722b - Fixes caching bug of NIP-96 servers
- Allow NIP-96 setups with relative URLs
- Displays error messages if the server has sent in the body
- Adds a test case for both
2024-08-30 18:42:41 -04:00
Vitor Pamplona
b733fbce35 Merge pull request #1036 from Dawn11111111/patch-1
Typo Fix on Lnaddress TextField
2024-08-30 12:05:00 -04:00
Dawn
93e9e23730 Typo Fix on Lnaddress TextField
Removed 'i' from lightining :)
2024-08-30 09:45:13 -06:00
Vitor Pamplona
388ccdbe75 Speeds up the filter for NWC zap payments. 2024-08-29 12:32:34 -04:00
Vitor Pamplona
255f464046 Basic code for dynamic limits 2024-08-29 10:29:46 -04:00
Vitor Pamplona
8be74649ce Protects against empty nip04 content 2024-08-28 17:59:59 -04:00
Vitor Pamplona
b5709f9527 Adds basic support for NIP-46 events. 2024-08-28 17:39:25 -04:00
Vitor Pamplona
7d9efcd1d9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-28 16:07:47 -04:00
Vitor Pamplona
d44f5dad18 Merge pull request #1035 from greenart7c3/main
Show relay ping with the relay icon
2024-08-28 16:06:50 -04:00
greenart7c3
06f37ab81d Fix relay icons when using complete ui 2024-08-28 16:20:55 -03:00
Vitor Pamplona
cb4a73bb9c Enables decryption by nip04 and nip44 on NostrWalletConnect objects, NIP-51 lists and NIP-04 messages 2024-08-28 15:20:25 -04:00
greenart7c3
efde14a348 Show relay ping with the relay icon 2024-08-28 16:17:08 -03:00
Vitor Pamplona
0f86e3d8fb Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-28 09:20:48 -04:00
Vitor Pamplona
1407850e81 Fixes push registration with empty token ids 2024-08-28 09:20:40 -04:00
Vitor Pamplona
d9a87f6e7c Merge pull request #1034 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-28 08:33:37 -04:00
Crowdin Bot
5e0a49f225 New Crowdin translations by GitHub Action 2024-08-28 12:31:37 +00:00
Vitor Pamplona
3ebaf2bf1e Fixes ZapTheDevs card not disappearing after the donation. 2024-08-28 08:29:53 -04:00
Vitor Pamplona
9ef0ceed9d Merge pull request #1033 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-27 17:49:17 -04:00
Crowdin Bot
caa3760fd3 New Crowdin translations by GitHub Action 2024-08-27 21:46:25 +00:00
Vitor Pamplona
0b2a9e67c0 v0.90.3 2024-08-27 17:44:44 -04:00
Vitor Pamplona
931081c5e7 Exposes decryption crashing because it is used by Notification Services to check which account can decrypt a GiftWrap. 2024-08-27 17:43:12 -04:00
Vitor Pamplona
f7237b5666 Improves logs on new Notifications 2024-08-27 17:42:18 -04:00
Vitor Pamplona
d851637ea2 Fixes byte to string decoder for UnifiedPush 2024-08-27 17:41:56 -04:00
Vitor Pamplona
2d39b4edfe Only register for endpoints if it has changed from the one in memory 2024-08-27 17:41:37 -04:00
Vitor Pamplona
5001c5bef8 There is no need to extend onReceive 2024-08-27 17:40:53 -04:00
Vitor Pamplona
6ac1db9fc8 correctly passes the endpoint to remove it 2024-08-27 17:40:35 -04:00
Vitor Pamplona
52f33d79e0 Correctly registering the distributor at startup 2024-08-27 17:38:14 -04:00
Vitor Pamplona
1a9d91cdc0 Fixes correct saving of UnifiedPush endpoints 2024-08-27 17:37:53 -04:00
Vitor Pamplona
2f444e6ec5 Moves ChatMessage components to the chatroom package 2024-08-27 16:03:15 -04:00
Vitor Pamplona
4665c488d5 Adds a small border between the author and the message of a chat bubble 2024-08-27 15:57:56 -04:00
Vitor Pamplona
42ab826d02 v0.90.2 2024-08-27 15:01:50 -04:00
Vitor Pamplona
80a3f7424b fixes conversation tab 2024-08-27 14:52:22 -04:00
Vitor Pamplona
1bd3e347b3 Updates media 3 dependencies. 2024-08-27 14:51:30 -04:00
Vitor Pamplona
a332126e71 Quick fix for to not show error messages on Zap the Devs 2024-08-27 14:33:57 -04:00
Vitor Pamplona
d97abe6af9 v0.90.0 2024-08-27 13:48:36 -04:00
Vitor Pamplona
62d341bd8a Publishing new user's events 2024-08-27 11:49:43 -04:00
Vitor Pamplona
a95d2e903e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt
2024-08-27 11:04:36 -04:00
Vitor Pamplona
2b5e62aaf7 - Improves rendering of the QR Code elements
- Makes the QR bigger
- Moves QR Code to rounder corners
- Moves QR Screen to use the arrow icon instead of the close button
2024-08-27 11:04:13 -04:00
Vitor Pamplona
1c3e82913f Merge pull request #1031 from greenart7c3/main
Use white background for qrcodes
2024-08-27 11:02:33 -04:00
Vitor Pamplona
ceaa20fabf Removes galleries live data 2024-08-27 08:34:25 -04:00
Vitor Pamplona
1bbabb747e Deleting Bundled updates for Account. 2024-08-26 19:12:39 -04:00
Vitor Pamplona
914c0a2926 Making sure scopes are cancelled for accountViewModel notification dot updates. 2024-08-26 19:12:28 -04:00
Vitor Pamplona
4b249156f9 Makes sure the accountViewModel is deleted when the user switches the screen, logs off or kills the activity. 2024-08-26 18:22:34 -04:00
Vitor Pamplona
7b8c7a1a78 Simplifies flow collection for new events. 2024-08-26 18:22:12 -04:00
Vitor Pamplona
66ed26dcea Finishes the migration of the service manager to the Application class 2024-08-26 18:21:53 -04:00
Vitor Pamplona
356740479c Moves to hold the feed itself in a stateflow 2024-08-26 17:48:27 -04:00
Vitor Pamplona
c88dded01b Improves stability of composables 2024-08-26 16:45:07 -04:00
Vitor Pamplona
7c974bc8ef adds a directory for metrics as well 2024-08-26 15:27:04 -04:00
Vitor Pamplona
efbc3e3e78 removes unecessary logs 2024-08-26 14:57:16 -04:00
Vitor Pamplona
fac803e208 Merge pull request #1032 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-26 14:56:04 -04:00
Crowdin Bot
484a3d6b32 New Crowdin translations by GitHub Action 2024-08-26 18:44:35 +00:00
Vitor Pamplona
4e3b6d0299 - Adds a sync Signer to facilitate library
- Separates Account actions from Account state in two objects
- Changes Startup procedures to start with Account state and not the full account object
- Moves scope for flows in Account from an Application-wide scope to ViewModel scope
- Removes all LiveData objects from Account in favor of flows from the state object
- Migrates settings saving logic to flows
- Migrates PushNotification services to work without Account and only Account Settings.
- Migrates the spam filter from LiveData to Flows
- Adds Default lists for NIP-65 inbox and outbox relays
- Adds Default lists for Search relays
- Adds local backup for UserMetadata objects
- Adds local backup for Mute lists
- Adds local backup for NIP-65 relays
- Adds local backup for DM Relays
- Adds local backup for private home relays
- Rewrites state flows initializers to avoid inconsistent startups
2024-08-26 14:42:55 -04:00
Vitor Pamplona
f3161ada8d Solves crash when opening the Relay screen with empty urls as relays. 2024-08-26 11:13:10 -04:00
greenart7c3
5c9b84476b Use white background for qrcodes 2024-08-26 07:21:19 -03:00
Vitor Pamplona
24ea0cabda Updates benchmark, composeBOM and firebaseBOM to the latest versions 2024-08-23 08:35:44 -04:00
Vitor Pamplona
8b617fbd60 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-22 17:27:04 -04:00
Vitor Pamplona
34149e6ce2 Merge pull request #1030 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-22 17:13:16 -04:00
Vitor Pamplona
315a662c10 Improve horizontal padding of the chat messages 2024-08-22 17:12:46 -04:00
Crowdin Bot
c373843e0f New Crowdin translations by GitHub Action 2024-08-22 21:03:22 +00:00
Vitor Pamplona
448ea796ef Improves caching of encrypted DMs:
- Migrates caching of decrypted value outside of the Event class
- Removes encrypted parts of NIP-17 from the cache
- Removes old NIP-04 messages from the cache
- Avoids deleting new NIP-17 plain text chats from memory
2024-08-22 17:01:37 -04:00
Vitor Pamplona
71b111ce8b Fixes the download of 1000s of NIP-65 relay lists because some relays consider empty lists as null and return everything. 2024-08-22 12:24:45 -04:00
Vitor Pamplona
ae686671ba Minimizing the number of filters for group chat metadata 2024-08-22 09:55:28 -04:00
Vitor Pamplona
6c45bb1ca1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-21 20:32:06 -04:00
Vitor Pamplona
a05ad6e686 Adds support for NIP-35 torrents and their comments 2024-08-21 20:31:59 -04:00
Vitor Pamplona
485f7becd4 Merge pull request #1029 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-21 19:18:29 -04:00
Vitor Pamplona
83f1e523ea Creates links to njump when events can't be found on Amethyst 2024-08-21 17:05:14 -04:00
Vitor Pamplona
a8a3c94115 clear flows before removing notes from the local cache 2024-08-21 16:32:11 -04:00
Vitor Pamplona
4d21857adf moves the processing of nip19 links to the default thread 2024-08-21 16:31:57 -04:00
Crowdin Bot
0e7f41efe8 New Crowdin translations by GitHub Action 2024-08-21 20:09:17 +00:00
Vitor Pamplona
8d9bf60dba Merge pull request #1028 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-21 16:08:51 -04:00
Vitor Pamplona
7c67924914 fixes imports 2024-08-21 16:07:42 -04:00
Crowdin Bot
7626839037 New Crowdin translations by GitHub Action 2024-08-21 19:59:14 +00:00
Vitor Pamplona
23825ceed5 Avoids bottom nav recompositions 2024-08-21 15:57:45 -04:00
Vitor Pamplona
8d8016e01b Simplifies nav bar selected calculations 2024-08-21 15:51:01 -04:00
Vitor Pamplona
6668e5a178 Moves accountViewModelMock to a more appropriate file 2024-08-21 15:14:28 -04:00
Vitor Pamplona
7d6bef9576 Moves heavy computations away from IO 2024-08-21 15:00:13 -04:00
Vitor Pamplona
1aea7fbd03 Avoids remembering edit lists that will never exist. 2024-08-21 14:58:01 -04:00
Vitor Pamplona
ef96d296f8 avoids double update to the viewmodel 2024-08-21 14:53:35 -04:00
Vitor Pamplona
d4da58e14a Improves logs when the UserMetadata cannot be parsed 2024-08-21 14:38:17 -04:00
Vitor Pamplona
f646866192 moves markasread from io to default 2024-08-21 09:20:43 -04:00
Vitor Pamplona
e0849b12e7 Merge pull request #1027 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-21 07:55:58 -04:00
Crowdin Bot
1271343582 New Crowdin translations by GitHub Action 2024-08-21 11:52:51 +00:00
Vitor Pamplona
d61d14ea75 Merge pull request #1026 from davotoula/chage-twitter-to-x
Change twitter proof to use x.com
2024-08-21 07:51:04 -04:00
David Kaspar
668d604aa9 use original twitter colour in x.xml because I can't get the theme switching working 2024-08-21 11:56:45 +01:00
Vitor Pamplona
6df2c3a0cf improving speed of chatlist rendering 2024-08-20 15:23:15 -04:00
Vitor Pamplona
b0c33808e6 Adds support for MOD reports 2024-08-20 15:07:28 -04:00
Vitor Pamplona
7bd3e0beab Improved padding and spacing for chat messages 2024-08-20 13:05:44 -04:00
Vitor Pamplona
3a70cfb9e2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-20 09:14:56 -04:00
Vitor Pamplona
17c758f8f8 Displays PoW of commitment if present. 2024-08-20 09:14:47 -04:00
Vitor Pamplona
7cb03b939f Merge pull request #1022 from KotlinGeekDev/fix-relay-set
Fix relay set event.
2024-08-20 08:57:34 -04:00
KotlinGeekDev
481ef91cb1 Change relay set kind to be NIP-51 consistent. 2024-08-20 12:13:42 +01:00
Vitor Pamplona
1183aa8d11 Changes fdroid push registration to re-register even if the saved distributor was already selected. 2024-08-19 17:58:50 -04:00
Vitor Pamplona
4023261400 Merge pull request #1020 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-19 12:07:35 -04:00
Crowdin Bot
f1433504e9 New Crowdin translations by GitHub Action 2024-08-19 16:00:50 +00:00
Vitor Pamplona
989cbb35d3 Disabling landscape rotation on full screen due to portrait videos. 2024-08-19 11:58:50 -04:00
Vitor Pamplona
9262b90096 Merge pull request #1019 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-19 11:40:22 -04:00
Crowdin Bot
ab3b7685cf New Crowdin translations by GitHub Action 2024-08-19 15:40:02 +00:00
Vitor Pamplona
40865ac97d Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-19 11:36:39 -04:00
Vitor Pamplona
ded67964b0 Merge pull request #1018 from KotlinGeekDev/fullscreen-video-behavior
Change tap to fullscreen video behavior for livestreams.
2024-08-19 11:34:47 -04:00
Vitor Pamplona
e991707ddf updates runner 2024-08-19 11:32:04 -04:00
KotlinGeekDev
1b429475aa Remove the manual testing/debugging code. Remove the LocalConfiguration API in favour of DeviceUtils.isLandscapeMetric. 2024-08-17 18:51:07 +01:00
KotlinGeekDev
9613afb7ea Fix ZoomableImageDialog. isFoldableOrLarge already takes into account the orientation. 2024-08-17 17:07:43 +01:00
KotlinGeekDev
26a12f2dbe Remove the external config method and use the built-in Compose API.
Add some documentation.
2024-08-17 17:03:07 +01:00
KotlinGeekDev
23da02f932 Merge remote-tracking branch 'origin/fullscreen-video-behavior' into fullscreen-video-behavior 2024-08-17 15:19:47 +01:00
KotlinGeekDev
59ff9007a7 Move DeviceUtils closer to call-site. Copy code from Material3-Adaptive library(to avoid experimental annotation propagation everywhere).
Implement windowIsLarge to take care of device type handling(sort-of). Add some funky debug methods to make sure windowIsLarge works.
2024-08-17 14:18:48 +00:00
KotlinGeekDev
0f817dbf00 Put device in landscape mode when fullscreen button is pressed. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
7ab6322886 Introduce DeviceUtils, and add functions for changing orientation, and checking for Landscape mode. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
223c0c1263 Initial implementation for auto-fullscreen on rotation to landscape mode. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
d3a639a2ee Consistent naming and behavior for the video player controls. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
2fb1cbe11f Move DeviceUtils closer to call-site. Copy code from Material3-Adaptive library(to avoid experimental annotation propagation everywhere).
Implement windowIsLarge to take care of device type handling(sort-of). Add some funky debug methods to make sure windowIsLarge works.
2024-08-17 15:14:24 +01:00
Vitor Pamplona
29c5b45764 Moves the default zap type to a state flow and avoids passing on to the screen when using the default value. 2024-08-16 17:45:53 -04:00
Vitor Pamplona
ec83ad9d20 Removing unecessary livedata objects for translation services 2024-08-16 17:10:02 -04:00
Vitor Pamplona
239f973e89 Moves lastread routes to mutableStateFlow 2024-08-16 16:42:00 -04:00
Vitor Pamplona
6c81fbbcf9 Fixes crash when the relay url of a user is duplicated. 2024-08-16 16:00:03 -04:00
Vitor Pamplona
75e45f37b2 Migrating livedata to flow for contact list updates of the user. 2024-08-16 15:59:43 -04:00
Vitor Pamplona
0bff3462b1 Migrates the pubkeyHex from keypair to the signer. 2024-08-16 15:58:23 -04:00
Vitor Pamplona
990db68a4d Fixes spacing of short quotes 2024-08-16 13:12:05 -04:00
Vitor Pamplona
4343c744db Displaying stacks in error messages if they are present 2024-08-16 11:14:34 -04:00
Vitor Pamplona
5cb6f97178 linting cleanup 2024-08-16 10:48:30 -04:00
KotlinGeekDev
a8efc9d348 Put device in landscape mode when fullscreen button is pressed. 2024-08-16 14:53:40 +01:00
Vitor Pamplona
e0b667fd2d Fixes order for the notification feed after refactoring 2024-08-16 09:41:38 -04:00
Vitor Pamplona
a8ad2437fe refactors the Watch feed to scroll to the top 2024-08-16 09:39:15 -04:00
KotlinGeekDev
83018e621e Introduce DeviceUtils, and add functions for changing orientation, and checking for Landscape mode. 2024-08-16 14:19:55 +01:00
Vitor Pamplona
68b15ba483 Refactors old user zap object. 2024-08-15 17:08:15 -04:00
Vitor Pamplona
7513036d47 Refactors sorting of Notifications 2024-08-15 16:16:36 -04:00
Vitor Pamplona
c82c594926 Fixes slow down when the last message on chat is a base64 image. 2024-08-15 16:02:28 -04:00
Vitor Pamplona
bd18d4b4b2 Adjusts Outbox filter as soon as a new NIP-65 relay list shows up 2024-08-15 15:17:41 -04:00
Vitor Pamplona
2f5b99a1c7 Merge pull request #1016 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-15 15:06:50 -04:00
Crowdin Bot
1be6e0ec0f New Crowdin translations by GitHub Action 2024-08-15 18:58:39 +00:00
Vitor Pamplona
f43c0e5cf5 Moves error messages from uploading procedures to the full error dialog instead of just a toast. 2024-08-15 14:55:25 -04:00
Vitor Pamplona
d6d8719c8d Fixes the centralization of the "and more" part of relay recommendations 2024-08-14 18:50:26 -04:00
Vitor Pamplona
7478899388 Fixes miscaching flows of the relay lists from follows. 2024-08-14 18:03:25 -04:00
Vitor Pamplona
6b193894bd Fixes miscache of hashtag following button 2024-08-14 17:16:02 -04:00
Vitor Pamplona
69f5371ab8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-14 17:15:36 -04:00
Vitor Pamplona
526e3db3c1 Adds more information to when error messages are not available in the relay stats. 2024-08-14 15:50:05 -04:00
Vitor Pamplona
3c9e8854eb Merge pull request #1015 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-14 15:14:42 -04:00
Crowdin Bot
7ee0aa47a9 New Crowdin translations by GitHub Action 2024-08-14 19:07:04 +00:00
Vitor Pamplona
5fbf0462fa No need to check for the MainThread in every updateFeedWith 2024-08-14 15:04:51 -04:00
Vitor Pamplona
eda5bc50e6 Adds a destroy method to FollowList state for consistency 2024-08-14 10:45:57 -04:00
David Kaspar
d6d26d466b clean up code in MetadataEvent.kt
update x drawable
2024-08-14 08:11:28 +01:00
Vitor Pamplona
6acd3ca091 Moves follow list states to the AccountViewModel 2024-08-13 19:35:04 -04:00
Vitor Pamplona
c64d179f7f Migrates Notification Summary to the new state model 2024-08-13 18:56:53 -04:00
Vitor Pamplona
32bc8fe667 Fixes f-droid flavor after refactoring 2024-08-13 18:35:31 -04:00
Vitor Pamplona
0a8c792d53 Moves the notification screen to the new state model instead of viewModels 2024-08-13 18:32:20 -04:00
Vitor Pamplona
c32dc61ea1 Refactoring Moving feed status from ViewModel to State objects 2024-08-13 17:32:15 -04:00
Vitor Pamplona
3af2cb52cc adds a prune for giftwrapped messages 2024-08-13 15:11:15 -04:00
Vitor Pamplona
ddb1d7ff82 Adds context to highlight events 2024-08-13 15:08:32 -04:00
KotlinGeekDev
fd0523085d Initial implementation for auto-fullscreen on rotation to landscape mode. 2024-08-13 10:42:49 +01:00
Vitor Pamplona
1f9fbb0c86 Revers letter spacing in inline code on Markdow 2024-08-12 15:58:17 -04:00
Vitor Pamplona
5b95a1c177 Improved logs when clearing the cache for DMs 2024-08-12 15:55:25 -04:00
Vitor Pamplona
2e144ebdf8 Adds previews to test markdown rendering 2024-08-12 15:55:01 -04:00
Vitor Pamplona
ac5f550ff3 Fixes heading sizes on markdown 2024-08-12 15:54:20 -04:00
Vitor Pamplona
9af123b709 Improves the look of inlinde code in markdown 2024-08-12 15:53:45 -04:00
Vitor Pamplona
75efd2b372 Improves badge display 2024-08-12 11:18:22 -04:00
Vitor Pamplona
2cface9ac9 Inverts spam logs 2024-08-12 11:11:23 -04:00
Vitor Pamplona
58491a97ac Merge pull request #1012 from greenart7c3/main
show only 3 users in the recommended relays section
2024-08-12 09:07:21 -04:00
greenart7c3
7a86d78c50 show only 3 users in the recommended relays section 2024-08-12 09:05:08 -03:00
David Kaspar
97a00ff83c First pass updating Twitter verification to X 2024-08-12 11:07:48 +02:00
Vitor Pamplona
4c6842faf4 v0.89.10 2024-08-09 18:47:48 -04:00
Vitor Pamplona
fad8b9df0f Improved filter for notifications 2024-08-09 18:47:38 -04:00
Vitor Pamplona
cc52681635 Moves service manager to the Application class 2024-08-09 18:14:09 -04:00
Vitor Pamplona
939c8e95b0 Adds protections against filters with empty arrays because some relays consider that null as opposed to empty. 2024-08-09 18:13:19 -04:00
Vitor Pamplona
312501e527 AGP to 8.5.2 2024-08-09 15:25:52 -04:00
Vitor Pamplona
89638ff261 Deletes all events in maximum chunks of 200 elements to avoid the 65KB stringified JSON limit of many relays. 2024-08-09 14:35:00 -04:00
Vitor Pamplona
27d2e2309a Fixes the clickable regions to open additional relay info on the relay list dialog. 2024-08-09 13:44:44 -04:00
Vitor Pamplona
7eaa553ebe Allows users to select and copy the notice from the relay on the relay list dialog 2024-08-09 13:43:53 -04:00
KotlinGeekDev
8adddd0bdb Consistent naming and behavior for the video player controls. 2024-08-09 15:26:42 +01:00
Vitor Pamplona
d8f084f886 Merge pull request #1009 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-08 18:37:10 -04:00
Crowdin Bot
5c5a23dfbb New Crowdin translations by GitHub Action 2024-08-08 22:30:34 +00:00
Vitor Pamplona
282d4614c0 Adds zap amount cache for the memory space calculations 2024-08-08 18:19:48 -04:00
Vitor Pamplona
8ed78ea38f Marks username as deprecated 2024-08-08 18:18:54 -04:00
Vitor Pamplona
2e7b7c5c00 improves wording on the name of relays 2024-08-08 14:35:45 -04:00
Vitor Pamplona
68cad97819 Reducing the amount of CPU memory used for images to the default. 2024-08-08 11:51:35 -04:00
Vitor Pamplona
02f7b54661 Updates translate dependencies 2024-08-08 08:31:38 -04:00
Vitor Pamplona
e887232132 v0.89.9 2024-08-07 18:39:02 -04:00
Vitor Pamplona
7bcf4e3750 Removes logs from previous commit 2024-08-07 18:34:38 -04:00
Vitor Pamplona
ed0676a5f5 Fixes the order of bookmarks (keeps the order of the event, instead of the created at) 2024-08-07 18:24:35 -04:00
Vitor Pamplona
f731c654b0 Improves the async rendering of Base64 content 2024-08-07 18:24:14 -04:00
Vitor Pamplona
b026bffe4a Moves discovery and video lists to Outbox when Follows or relay lists are selected 2024-08-07 16:39:24 -04:00
Vitor Pamplona
a1aaec0b7d Adds support for selecting authors based on their Outbox relays when searching for notes authored by them 2024-08-07 15:46:27 -04:00
Vitor Pamplona
6f59097ac0 Aligns default note comparator to NIP-01's created at descending and then by id ascending 2024-08-07 15:44:35 -04:00
Vitor Pamplona
263df0c895 Renames PerRelayFilter to SincePerRelay
Creates PerRelay interface to allow other types of filters
2024-08-07 15:44:03 -04:00
Vitor Pamplona
1ca5713494 Merge pull request #1008 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-07 12:57:34 -04:00
Crowdin Bot
b706c4e871 New Crowdin translations by GitHub Action 2024-08-07 16:04:54 +00:00
Vitor Pamplona
e8574c10bb Keep them public to allow testing in these particular functions 2024-08-07 12:02:48 -04:00
Vitor Pamplona
7cb87ea9c5 Refactors to use native contains instead of custom lambdas on Ammolite's Filter 2024-08-07 11:58:00 -04:00
Vitor Pamplona
07e5132943 Refactors Ammolite Filters to be regular ones and creates a PerRelayFilter for the use on Amethyst. 2024-08-07 11:40:01 -04:00
Vitor Pamplona
2b1e3cfc93 Renames the MinimumRelayList to RecommendationProcessor 2024-08-07 11:39:14 -04:00
Vitor Pamplona
c17e1f0e1f removes logs from previous commit 2024-08-06 17:14:22 -04:00
Vitor Pamplona
e88d1d0d00 Adds haptic feedback to draft deletion swipe 2024-08-06 17:13:06 -04:00
Vitor Pamplona
5fdff97cf8 Moves the ContactList cache lists to AccountViewModel, where it can be disposed more efficiently. 2024-08-06 15:36:04 -04:00
Vitor Pamplona
e5328d7975 Improves the accuracy of the Event memory counter. 2024-08-06 15:35:14 -04:00
Vitor Pamplona
71b45b96fb Adds event factory performance test 2024-08-06 11:04:30 -04:00
Vitor Pamplona
dee28f3aa8 Merge pull request #1006 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-05 17:26:13 -04:00
Crowdin Bot
3a118bb939 New Crowdin translations by GitHub Action 2024-08-05 20:59:07 +00:00
Vitor Pamplona
971c92b27a Adds extension possibility to Quartz's event factory 2024-08-05 16:57:21 -04:00
Vitor Pamplona
4c70065843 Moves DataSource dispatcher from IO to Default 2024-08-05 16:02:35 -04:00
Vitor Pamplona
eab69337ff Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-05 16:02:19 -04:00
Vitor Pamplona
ab772eb65e Makes stringRes Stable for compose 2024-08-05 15:48:06 -04:00
Vitor Pamplona
7a6720a7ab Merge pull request #1005 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-05 15:31:47 -04:00
Crowdin Bot
0f3dc18f7a New Crowdin translations by GitHub Action 2024-08-05 19:16:01 +00:00
Vitor Pamplona
84df86f4b6 Removes mutiny NWC button :( 2024-08-05 15:13:35 -04:00
Vitor Pamplona
a0289a0cb4 Moves Relay viewModels to Default thread 2024-08-05 15:08:08 -04:00
Vitor Pamplona
3c034ca8af v0.89.8 2024-08-02 16:34:51 -04:00
Vitor Pamplona
0c8c3003aa Merge pull request #1004 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-02 13:37:05 -04:00
Crowdin Bot
919dabfefa New Crowdin translations by GitHub Action 2024-08-02 16:39:19 +00:00
Vitor Pamplona
fd06193e93 Notifies relay failures after marking them as closed. 2024-08-02 12:36:54 -04:00
Vitor Pamplona
b02fa43628 Print relay writes on benchmark as well. 2024-08-02 12:35:52 -04:00
Vitor Pamplona
7fdf38d17b Normalizes logs for the DataSources 2024-08-02 12:35:25 -04:00
Vitor Pamplona
63e036a9bb Minimizes relay updates when rotating status events for each user. 2024-08-02 12:32:44 -04:00
Vitor Pamplona
fc98442f8b Adds support for CashuB tokens using CBOR 2024-07-31 17:36:48 -04:00
Vitor Pamplona
3bbb780d2b Simplifies the drawing of gallery entires without e tags. 2024-07-31 16:02:18 -04:00
Vitor Pamplona
42ceb0c404 Fixes test case for ImageUploaders 2024-07-31 15:59:07 -04:00
Vitor Pamplona
046bd7a0e9 Merge pull request #1003 from greenart7c3/main
Add delete all drafts button
2024-07-31 14:58:26 -04:00
Vitor Pamplona
0af0f745bf Fixes the need for main thread note loading for galleries 2024-07-31 14:57:44 -04:00
Vitor Pamplona
b24999d78c Basic refactoring to minimize the need for user-space params in the VideoView 2024-07-31 14:43:10 -04:00
Vitor Pamplona
d1278a4477 Better rendering for live streams that are planned but already finished. 2024-07-31 14:16:44 -04:00
greenart7c3
609ccaa635 Add delete all drafts button 2024-07-31 13:51:02 -03:00
Vitor Pamplona
4bae04ad31 Merge pull request #1001 from believethehype/better_gallery_view
Gallery: use FileHeader Info for rendering / faster loading
2024-07-31 10:41:19 -04:00
Believethehype
8bca729edd Merge remote-tracking branch 'upstream/main' into better_gallery_view 2024-07-31 16:36:25 +02:00
Vitor Pamplona
2b2f04f724 Adds the callbackUri to the display uncited hashtags so that it can reuse the previously parsed post. 2024-07-31 10:21:13 -04:00
Vitor Pamplona
231af1d3d8 Fixes #993 2024-07-31 10:17:19 -04:00
Believethehype
d1b44c0ac0 remove print 2024-07-31 12:59:23 +02:00
Believethehype
f06c3f7376 massive speed up of rendering the gallery 2024-07-31 12:55:02 +02:00
Believethehype
b8e10b040d cleanup 2024-07-31 12:02:39 +02:00
Believethehype
1a4396dbe3 removes unneded variable 2024-07-31 11:51:44 +02:00
Believethehype
856eb0da05 create GalleryFeed earlier 2024-07-31 11:40:59 +02:00
Believethehype
ceaeb8e0b9 use GalleryContentView instead of 2024-07-31 11:24:05 +02:00
Vitor Pamplona
b29ccd9746 Refactors the lambda used for content resolvers. 2024-07-30 18:16:28 -04:00
Vitor Pamplona
f82364939f Adds icons in SVG to the docs 2024-07-30 18:16:06 -04:00
Vitor Pamplona
286583bba2 fixing the need for a new coroutine startup 2024-07-30 13:50:08 -04:00
Vitor Pamplona
bc8e5aa458 Merge pull request #997 from VASHvic/fix-clipboard-copy-edited-notes
Fix copying to clipboard edited notes
2024-07-30 13:47:49 -04:00
Vitor Pamplona
b393fb042f Merge pull request #999 from believethehype/nutrendering
Show mint in cashu token rendering
2024-07-30 13:47:14 -04:00
Vitor Pamplona
0258c5aac2 Releasing Flows if not subscribed anymore. 2024-07-30 13:22:48 -04:00
Vitor Pamplona
a03a11c4cc Updates depedencies 2024-07-30 13:17:53 -04:00
Vitor Pamplona
3896bbab34 Merge pull request #1000 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-30 09:17:19 -04:00
Crowdin Bot
5459f31000 New Crowdin translations by GitHub Action 2024-07-29 23:24:58 +00:00
Vitor Pamplona
143c3a1444 Testing that both 02 and 03 compressed keys are valid Nostr keys and can encrypt and decrypt to each other. 2024-07-29 19:23:22 -04:00
Believethehype
bfbcf11fa3 Update CashuRedeem.kt 2024-07-29 20:42:17 +02:00
VASH
25823b1f39 Fix copying to clipboard edited notes 2024-07-27 13:05:20 +02:00
Believethehype
cb02955ac6 fix order of Share ImageAction 2024-07-24 07:49:44 +02:00
Vitor Pamplona
b440661e0d Adds the highlight to the base URL so that when the user opens the link, it highlights on the page. 2024-07-23 12:56:53 -04:00
Vitor Pamplona
38ba456a22 Signs for just one auth event to register with the push notification service instead of the dozens of events, one per relay. 2024-07-23 10:15:36 -04:00
Vitor Pamplona
bca941e045 enables crossfading between images 2024-07-23 08:47:20 -04:00
Vitor Pamplona
dbaadb4595 v0.89.7 2024-07-22 18:10:15 -04:00
Vitor Pamplona
231ca44e1c Avoids recomposition of the entire line when changing status. 2024-07-22 18:07:44 -04:00
Vitor Pamplona
ca00c87a6e Prints memory use in descending order by size 2024-07-22 16:59:58 -04:00
Vitor Pamplona
2a28111583 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-22 14:27:23 -04:00
Vitor Pamplona
4def80c914 updates the EOSE of users even if no user is found in the relay. 2024-07-22 14:27:10 -04:00
Vitor Pamplona
916ece1edb Merge pull request #992 from greenart7c3/main
Remove focus from status text field when closing the drawer
2024-07-22 14:00:24 -04:00
Vitor Pamplona
3c1f57fac0 Sorts keys before making the filter. 2024-07-22 13:57:31 -04:00
greenart7c3
e166c36731 Remove focus from status text field when closing the drawer 2024-07-22 14:45:39 -03:00
Vitor Pamplona
a636f4a786 updates coil and jackson libs 2024-07-22 11:31:57 -04:00
Vitor Pamplona
bb127506c7 Merge pull request #985 from believethehype/main
Store additional File Metadata in Gallery Entries
2024-07-22 08:10:26 -04:00
Vitor Pamplona
92c704d227 Merge pull request #986 from greenart7c3/main
Add sendAndWaitForResponse to Client.kt
2024-07-22 08:10:11 -04:00
greenart7c3
e764ebbcb2 if the relay just connected send the event 2024-07-22 07:49:54 -03:00
greenart7c3
68fb34a8ad Merge branch 'main' into main 2024-07-22 07:36:43 -03:00
greenart7c3
02683ba829 Merge remote-tracking branch 'origin/main' 2024-07-22 07:34:36 -03:00
greenart7c3
754a1b3a87 change the log of onRelayStateChange 2024-07-22 07:33:21 -03:00
Vitor Pamplona
4b58da7be7 Merge pull request #991 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-21 15:22:31 -04:00
Crowdin Bot
1ac9c850e4 New Crowdin translations by GitHub Action 2024-07-21 18:32:48 +00:00
Vitor Pamplona
3cc4dc1414 Merge pull request #990 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-21 14:31:54 -04:00
Vitor Pamplona
62ae43e863 v0.89.6 2024-07-21 14:31:17 -04:00
Crowdin Bot
8149a93f2f New Crowdin translations by GitHub Action 2024-07-21 18:31:01 +00:00
Vitor Pamplona
5a60c3a595 Fixes stringResource cache 2024-07-21 14:29:05 -04:00
Vitor Pamplona
2ef2f0d1a5 adds the key to avoid applying to a separate navcontroller 2024-07-21 11:32:08 -04:00
Vitor Pamplona
52fef33662 run faster animations 2024-07-21 11:18:56 -04:00
Vitor Pamplona
a5c6f86b35 v0.89.5 2024-07-20 16:27:27 -04:00
Vitor Pamplona
b80008d695 Adds more memory stat dumping for production apps 2024-07-20 16:27:19 -04:00
Vitor Pamplona
fa06aefbf1 increases disk cache to 1GB and adds a memory cache policy. 2024-07-20 15:32:34 -04:00
Vitor Pamplona
ab9ede1e26 Merge pull request #987 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-20 15:08:00 -04:00
Crowdin Bot
479b1ab20a New Crowdin translations by GitHub Action 2024-07-20 19:04:48 +00:00
Vitor Pamplona
8ed3f84cb7 Fixes screen rendering below the 3-button navigation 2024-07-20 15:02:57 -04:00
Vitor Pamplona
c128e43fe5 v0.89.4 2024-07-19 17:10:22 -04:00
Vitor Pamplona
f9ddf76686 action bar fix 2024-07-19 17:09:56 -04:00
Vitor Pamplona
b6cb839fe7 v0.89.3 2024-07-19 16:44:30 -04:00
Vitor Pamplona
2b6a48f391 Fixes issue with super large follow lists. 2024-07-19 16:40:46 -04:00
Vitor Pamplona
d1da863d3c Making sure an empty relay list uses the default list from Amethyst 2024-07-19 16:14:14 -04:00
greenart7c3
99b7a8005a Merge branch 'main' into main 2024-07-19 14:13:36 -03:00
greenart7c3
3f3367258f return true if at least one relay replied with success 2024-07-19 13:29:09 -03:00
Vitor Pamplona
15ab955a7d Fixes alt of NIP-96 server lists 2024-07-19 11:55:46 -04:00
greenart7c3
5cf8b5aa12 change proguard to see if now the client.listener works 2024-07-19 12:49:04 -03:00
greenart7c3
eba6d03b53 check if listeners work now 2024-07-19 12:17:26 -03:00
greenart7c3
de85239046 check if listeners work now 2024-07-19 11:56:30 -03:00
Vitor Pamplona
9176151971 Fixes auth infinite loop with nostr.wine 2024-07-19 10:45:06 -04:00
greenart7c3
cbd8acbd01 check eose too 2024-07-19 11:14:31 -03:00
greenart7c3
19c9bfa819 Merge remote-tracking branch 'origin/main' 2024-07-19 11:14:18 -03:00
greenart7c3
80c369a6ae change default timeout to 15 seconds 2024-07-19 10:56:35 -03:00
greenart7c3
d0190c87a2 add more logs, add optional listener, fix await call 2024-07-19 10:56:35 -03:00
greenart7c3
62a2cba18b fix lint 2024-07-19 10:56:35 -03:00
greenart7c3
59b3ff1b94 Added sendAndWaitForResponse to Client.kt 2024-07-19 10:56:35 -03:00
greenart7c3
35423e1bb6 change default timeout to 15 seconds 2024-07-19 10:52:36 -03:00
greenart7c3
e125690000 add more logs, add optional listener, fix await call 2024-07-19 10:50:59 -03:00
Vitor Pamplona
eba5ecfd91 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-19 09:47:04 -04:00
Vitor Pamplona
3e43a75acd Fixes the keyboard overriding parts of the screen 2024-07-19 09:46:58 -04:00
greenart7c3
04bf9c07ac fix lint 2024-07-19 08:05:04 -03:00
greenart7c3
ac58f99560 Added sendAndWaitForResponse to Client.kt 2024-07-19 07:58:12 -03:00
Believethehype
df328ff139 Store additional File Metadata in Gallery Entries 2024-07-18 17:53:37 +02:00
Vitor Pamplona
60ba7786a0 Merge pull request #984 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-18 10:54:01 -04:00
Crowdin Bot
1773ad5a88 New Crowdin translations by GitHub Action 2024-07-18 14:30:23 +00:00
Vitor Pamplona
26faa2bc69 Migrating to edge to edge in preparation for Android 15 2024-07-18 10:28:19 -04:00
Vitor Pamplona
75c7d40f4e v0.89.2 2024-07-17 19:34:56 -04:00
Vitor Pamplona
aa4a3fe9af Doesn't show people lists that have 0 people in it. 2024-07-17 19:33:47 -04:00
Vitor Pamplona
a32f907d6c Fixes incorrectly marking EOSEs for addressables 2024-07-17 19:26:46 -04:00
Vitor Pamplona
d30e613b17 Fixes TopNav people lists not loading 2024-07-17 17:24:06 -04:00
Vitor Pamplona
0477f390c2 Merge pull request #983 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-17 16:54:43 -04:00
Crowdin Bot
15e5e2834a New Crowdin translations by GitHub Action 2024-07-17 20:39:46 +00:00
Vitor Pamplona
b1399d1c13 Separates init and restart from observables. 2024-07-17 16:35:48 -04:00
Vitor Pamplona
45446b4125 Fixes bug on not updating observablesByKindAndAuthor 2024-07-17 16:35:03 -04:00
Vitor Pamplona
094d7e71e2 Merge pull request #982 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-17 09:16:29 -04:00
Crowdin Bot
75423eaaab New Crowdin translations by GitHub Action 2024-07-17 13:13:57 +00:00
Vitor Pamplona
5e42fa5346 Adds additional logs for the size of observables in the Local Cache 2024-07-17 09:09:38 -04:00
Vitor Pamplona
bc6d5060cb Moves frame dropping logs from 20ms to 33ms give than many videos make the UI run at 30fps 2024-07-17 09:09:19 -04:00
Vitor Pamplona
e578327c5b Fixes the inability to click on the relay tab on User Profiles 2024-07-17 09:08:41 -04:00
Vitor Pamplona
ce839ed6d9 v0.89.1 2024-07-16 19:23:37 -04:00
Vitor Pamplona
197484dc1b Removes unnecessary id that was causing crashes. 2024-07-16 19:23:26 -04:00
Vitor Pamplona
60e8ae7b9a v0.89.0 2024-07-16 18:14:04 -04:00
Vitor Pamplona
64ae32d005 Logs when the main looper calls an amethyst handler 2024-07-16 14:54:58 -04:00
Vitor Pamplona
87b8e18f1c Improves logging message for dropped frames 2024-07-16 11:47:41 -04:00
Vitor Pamplona
47a9a6cc4e no message 2024-07-16 09:49:30 -04:00
Vitor Pamplona
1a313a9f78 Adds performance monitors for framedrops in benchmark mode 2024-07-16 09:42:17 -04:00
Vitor Pamplona
3554350ac1 Merge pull request #980 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-16 09:10:47 -04:00
Crowdin Bot
e2e912c81f New Crowdin translations by GitHub Action 2024-07-16 12:48:38 +00:00
Vitor Pamplona
fa71161996 Corrects the name of the live data on Local Cache. 2024-07-16 08:42:05 -04:00
Vitor Pamplona
8de4d461d4 Reduces interruptions to the main thread 2024-07-16 08:41:48 -04:00
Vitor Pamplona
f1e3f6a592 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-15 18:16:43 -04:00
Vitor Pamplona
234ef679c8 updates mockk 2024-07-15 18:16:32 -04:00
Vitor Pamplona
072000254f Merge pull request #979 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-15 18:08:57 -04:00
Crowdin Bot
34321af07a New Crowdin translations by GitHub Action 2024-07-15 21:57:29 +00:00
Vitor Pamplona
7da559a0c9 Fixes tests 2024-07-15 17:55:46 -04:00
Vitor Pamplona
0564a7e1f1 Adds dynamic relay pool recommendations to the relay list. 2024-07-15 17:34:08 -04:00
Vitor Pamplona
347b16ee8b Fixes the post button disappeering when the Relay Settings top label is too large 2024-07-15 15:34:08 -04:00
Vitor Pamplona
edb834db38 Fixes a bug on clicking the user profile but loading the wrong one 2024-07-15 15:32:59 -04:00
Vitor Pamplona
97d0cf56a7 Merge pull request #978 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-12 14:00:38 -04:00
Crowdin Bot
ad83d362da New Crowdin translations by GitHub Action 2024-07-12 17:58:49 +00:00
Vitor Pamplona
f15f6df2ae avoids processing anything else than src for spotless linting 2024-07-12 13:55:45 -04:00
Vitor Pamplona
567a86c8a4 Moving compiler to 12GB of ram. 2024-07-12 09:56:46 -04:00
Vitor Pamplona
16c82ed69c Updates firebase dependencies 2024-07-12 08:55:27 -04:00
Vitor Pamplona
a543ee54dc Moves AGP to 8.5.1 2024-07-11 18:27:04 -04:00
Vitor Pamplona
128831109a Merge pull request #977 from believethehype/gallery_alternative_version
GalleryEntryEvent: add relay hint to etag
2024-07-11 12:05:19 -04:00
Vitor Pamplona
9641940bd9 Updates relays that are sending old versions of replaceables or events that have been already deleted. 2024-07-11 11:36:59 -04:00
Vitor Pamplona
ad449ba239 Activates the top nav list building when deletion events are received as well 2024-07-11 11:34:40 -04:00
Vitor Pamplona
31e2fcdc21 Request updates from relays when showing the People Lists in the top bar. 2024-07-11 11:33:31 -04:00
Vitor Pamplona
e28b385038 Removes overloading ambiguity 2024-07-11 10:26:59 -04:00
Vitor Pamplona
8d9ecb2054 Merge pull request #960 from greenart7c3/base64
Convert base64 text to images/gifs
2024-07-11 10:26:15 -04:00
greenart7c3
c57c190344 Merge branch 'main' into base64 2024-07-11 10:42:16 -03:00
Believethehype
7dd319eb25 remove print 2024-07-11 10:40:39 +02:00
Believethehype
b4a059c742 Merge branch 'gallery_alternative_version' of https://github.com/believethehype/amethyst into gallery_alternative_version 2024-07-11 10:08:28 +02:00
Believethehype
43d140175c Merge remote-tracking branch 'upstream/main' into gallery_alternative_version 2024-07-11 08:19:29 +02:00
Vitor Pamplona
1a70a02b44 Fixes scoping issues with flattenMerge 2024-07-10 16:04:39 -04:00
believethehype
a476298c07 Merge branch 'main' into gallery_alternative_version 2024-07-10 21:58:29 +02:00
Believethehype
a0a7f28304 GalleryEntryEvent: add relay hint to etag 2024-07-10 21:53:25 +02:00
Vitor Pamplona
17109e7406 Merge pull request #973 from believethehype/gallery_alternative_version
Profile Gallery
2024-07-10 13:37:14 -04:00
believethehype
c50343cd04 Merge branch 'main' into gallery_alternative_version 2024-07-10 19:35:30 +02:00
Vitor Pamplona
8feac270f4 Removes unnecessary logs. 2024-07-09 18:00:25 -04:00
Vitor Pamplona
c7061066e9 Adds an outbox cache to the Relay class in order to resend events after authentication. 2024-07-09 17:30:16 -04:00
Vitor Pamplona
217870244b No need for material dependencies anymore. 2024-07-09 11:10:34 -04:00
Vitor Pamplona
6fd2282818 increasing memory for kotlin build 2024-07-09 10:55:02 -04:00
Vitor Pamplona
b360c3292f removes unnecessary minification for benchmark's debug flavor 2024-07-09 10:45:51 -04:00
Vitor Pamplona
2a9e1bb08c solves build slowdown on spotless dependencies. 2024-07-09 10:42:50 -04:00
Vitor Pamplona
aa4e6c051e formatting 2024-07-09 10:42:29 -04:00
Vitor Pamplona
f6b947430a Removes the bugfix for reproducible builds since it has been fixed. 2024-07-09 10:42:22 -04:00
Vitor Pamplona
4521317779 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-09 09:30:07 -04:00
Vitor Pamplona
25ba353488 Merge pull request #974 from KotlinGeekDev/fix-poll-options-behavior
Fix poll options behavior
2024-07-09 09:29:27 -04:00
Vitor Pamplona
2074b34ec3 Trying maxMetaspaceSize to see if Github Actions can build it. 2024-07-08 13:55:27 -04:00
Vitor Pamplona
f63731c9b6 Merge pull request #976 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-08 13:50:13 -04:00
Crowdin Bot
fc057f2b29 New Crowdin translations by GitHub Action 2024-07-08 17:34:57 +00:00
Vitor Pamplona
af4dfbf965 Making sure posts have content to appear in the feed. 2024-07-08 13:33:16 -04:00
Vitor Pamplona
63c5098558 Pausing discovery top nav list watcher. 2024-07-08 13:33:01 -04:00
Believethehype
dcad12f836 load older notes in feed 2024-07-07 22:01:16 +02:00
KotlinGeekDev
08e84b056e Some cleanup. 2024-07-07 00:06:44 +01:00
KotlinGeekDev
f489deb808 Implement custom element removal with reordering. Works nicely, from observation. 2024-07-06 20:57:06 +01:00
Believethehype
511a7030b8 use alternative gallery using 1163 events instead of 10011 lists.
(This might still need some tuning but works in general)
2024-07-06 00:11:35 +02:00
Believethehype
06a1862d17 add relay info 2024-07-05 13:35:36 +02:00
greenart7c3
b730b8c6f6 Fix linter 2024-07-05 06:56:04 -03:00
greenart7c3
21a45844df Merge branch 'main' into base64 2024-07-05 06:35:52 -03:00
greenart7c3
2480b43778 Decode in the io thread 2024-07-05 06:31:58 -03:00
Believethehype
ad6dcfc030 use url tag, add relay hint 2024-07-05 08:29:49 +02:00
Believethehype
c81324843c move to G Tag for Gallery entries 2024-07-04 20:41:14 +02:00
Believethehype
a55da5f62e linting 2024-07-03 23:28:15 +02:00
Believethehype
9b26bdac5c move GalleryUrl to GalleryListEvent, merge recent changes 2024-07-03 23:11:59 +02:00
Believethehype
4bda6fa0cc Merge remote-tracking branch 'upstream/main' into gallery 2024-07-03 23:03:13 +02:00
Vitor Pamplona
1eaa22fe51 increasing gradle's memory to see if CI passes. 2024-07-03 14:37:59 -04:00
Vitor Pamplona
8e212bbb55 changing JVM of actions to zulu to see if java/kotlin compilation errors improve. 2024-07-03 14:16:16 -04:00
Vitor Pamplona
fdf0b69e5d Lint formatting 2024-07-03 13:49:57 -04:00
Vitor Pamplona
4b5ab8eb35 Removes unnecessary dependencies for ammolite 2024-07-03 13:49:43 -04:00
Vitor Pamplona
349d6cfc22 Migrates sdk requirements to the version catalog. 2024-07-03 13:49:18 -04:00
Vitor Pamplona
33b8267de7 Fixes linting 3 2024-07-03 11:35:39 -04:00
Vitor Pamplona
713bd59435 Fixes Linting 2 2024-07-03 11:33:38 -04:00
Vitor Pamplona
8c34394d86 Fixes linting problems 2024-07-03 11:21:22 -04:00
Vitor Pamplona
6e1de6fd26 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-03 11:06:10 -04:00
Vitor Pamplona
8d88efd27b Migrating Lint to 1.3.1 2024-07-03 11:06:02 -04:00
Vitor Pamplona
6aeaeea5d1 Merge pull request #970 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-03 11:03:30 -04:00
Crowdin Bot
3153296161 New Crowdin translations by GitHub Action 2024-07-03 15:02:50 +00:00
Vitor Pamplona
692f7c4270 Upgrades to Kotlin 2.0.0 2024-07-03 11:00:45 -04:00
believethehype
cc8d73e119 Merge branch 'main' into gallery 2024-07-03 13:35:30 +02:00
Vitor Pamplona
27c2c71879 Merge pull request #967 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-02 17:33:10 -04:00
Crowdin Bot
464beccd95 New Crowdin translations by GitHub Action 2024-07-02 21:32:31 +00:00
Vitor Pamplona
7519fc2f10 Adds Malware Report type 2024-07-02 17:30:03 -04:00
Vitor Pamplona
01816b0389 Adds a check to make sure the NIP-96 uploader is a url before adding to the list.
Solves NPE on the model if they are not urls
2024-07-02 17:29:16 -04:00
Believethehype
cc8e432336 make sure only owner of images get delete dialog in Gallery 2024-07-02 11:42:10 +02:00
Believethehype
d1ed73f78b Gallery: get rid of dtag, 2024-07-02 11:36:26 +02:00
Believethehype
7a76bf2ae5 remove unneeded dtag from 10011 2024-07-02 11:31:06 +02:00
KotlinGeekDev
6efb970794 Use keys of map entries, as they are more reliable(from testing). Extract pollViewModel.pollOptions into a separate variable. 2024-07-02 09:51:34 +01:00
believethehype
a0a8010305 Merge branch 'main' into gallery 2024-07-02 08:50:44 +02:00
Vitor Pamplona
9ed1dbac72 Updates lifecycle to 2.8.3 2024-07-01 17:13:37 -04:00
Vitor Pamplona
c7280cf1be Observes and shows only the user's selected NIP96 servers for uploading options 2024-07-01 17:12:01 -04:00
Believethehype
0ed87db3b3 moved to gallery tag 2024-07-01 22:45:50 +02:00
Vitor Pamplona
20bc814211 Fixes tag. From "relay" to "server" on kind 10096 2024-07-01 16:40:37 -04:00
believethehype
0c92c57b79 Merge branch 'main' into gallery 2024-07-01 22:40:31 +02:00
Vitor Pamplona
e61404146b Requests a NIP-96 compliant server 2024-07-01 16:34:44 -04:00
Vitor Pamplona
07dbcbf5c3 Adds FileServer events to be downloaded for the active account. 2024-07-01 16:34:28 -04:00
Believethehype
8421dbfa4c use r tag instead of g (reserved for geotag) 2024-07-01 22:06:40 +02:00
Believethehype
d2af7ba407 Merge remote-tracking branch 'upstream/main' into gallery 2024-07-01 22:03:30 +02:00
Vitor Pamplona
d0718fcda7 Merge pull request #953 from KotlinGeekDev/custom-media-servers
Support for custom media upload servers
2024-07-01 15:30:49 -04:00
believethehype
2372acbb2a Merge branch 'main' into gallery 2024-07-01 20:59:13 +02:00
greenart7c3
9374798f92 Revert "Use Base64Requester"
This reverts commit 2bd05d80b0.
2024-07-01 15:48:22 -03:00
greenart7c3
e96015bcbc Parse base64 inside the wordIdentifier function 2024-07-01 15:07:18 -03:00
greenart7c3
2bd05d80b0 Use Base64Requester 2024-07-01 14:50:19 -03:00
greenart7c3
3a7b503890 Show base64 images 2024-07-01 14:25:29 -03:00
Vitor Pamplona
d20707f4df Removes unused params 2024-07-01 12:51:45 -04:00
Vitor Pamplona
b01d308d65 Merge pull request #966 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-01 12:43:21 -04:00
Crowdin Bot
e0f5c730a6 New Crowdin translations by GitHub Action 2024-07-01 15:50:05 +00:00
Vitor Pamplona
b9086de336 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-01 11:48:06 -04:00
Vitor Pamplona
405d3d24a0 Removes decryption for DVM responses since it doesn't encrypt statuses 2024-07-01 11:47:58 -04:00
Vitor Pamplona
236de87574 Fixes unable to see original post when clicking on the edit button 2024-07-01 11:47:30 -04:00
Believethehype
b12d14d6f5 add a shortpreview to not mess up grid layout on load 2024-07-01 09:19:16 +02:00
Believethehype
1d42440e26 add gallery entry to dictionary 2024-07-01 08:21:00 +02:00
Believethehype
ec065a6595 readd regular video elements for regular playing, use isVideo funtion to check 2024-06-30 23:14:37 +02:00
Believethehype
0d83511509 Merge branch 'gallery' of https://github.com/believethehype/amethyst into gallery 2024-06-30 22:29:32 +02:00
Believethehype
9fe08f6c45 allow videos in the gallery 2024-06-30 22:29:11 +02:00
Believethehype
557f2e1a83 Merge remote-tracking branch 'upstream/main' into gallery 2024-06-30 18:02:43 +02:00
Vitor Pamplona
efced4670b Merge pull request #961 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-30 10:03:49 -04:00
Crowdin Bot
c89596809a New Crowdin translations by GitHub Action 2024-06-30 14:00:05 +00:00
Vitor Pamplona
7ccc93f17f Merge pull request #959 from greenart7c3/main
add onerror when websocket is null
2024-06-30 09:58:40 -04:00
greenart7c3
f50e511da0 Cut the text when the content length is greater than SHORT_TEXT_LENGTH and theres no spaces and new lines 2024-06-30 10:20:14 -03:00
greenart7c3
3d497ccf4d add onerror when websocket is null 2024-06-30 09:35:17 -03:00
Vitor Pamplona
5c21e219fe v0.88.8 2024-06-29 19:57:53 -04:00
Vitor Pamplona
58796ac31d - Adds created_at as the time of the notification instead of the receiving time
- Displays Zap Push notifications even if the zapped note is not available yet.
2024-06-29 18:51:41 -04:00
Vitor Pamplona
66317f5593 Better nip44 error messages for notifications 2024-06-29 16:20:49 -04:00
KotlinGeekDev
2f276cb155 Remove TODO, since a basic UI is already implemented. 2024-06-29 21:13:17 +01:00
Vitor Pamplona
b1b915b405 Merge pull request #958 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-29 15:43:01 -04:00
Crowdin Bot
052f9fa21f New Crowdin translations by GitHub Action 2024-06-29 19:38:24 +00:00
Vitor Pamplona
893b237d6a v0.88.7 2024-06-29 15:36:33 -04:00
Vitor Pamplona
e367f51458 merge 2024-06-29 15:34:16 -04:00
Vitor Pamplona
fa44b74f09 Adds a try/catch when receiving notifications to make sure it gets decrypted by the correct account. 2024-06-29 15:33:39 -04:00
believethehype
bb00616275 Merge branch 'main' into gallery 2024-06-29 15:31:54 +02:00
Believethehype
581d11702c Merge remote-tracking branch 'upstream/main' into gallery 2024-06-29 15:31:20 +02:00
Believethehype
16eb6097ce actually use the image from the 10011 event 2024-06-29 15:08:58 +02:00
Vitor Pamplona
2051a6b2f3 Merge pull request #956 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-29 08:10:25 -04:00
Crowdin Bot
02320548e3 New Crowdin translations by GitHub Action 2024-06-29 12:00:54 +00:00
Vitor Pamplona
44a0b59989 Merge pull request #955 from davotoula/update_translations
Update translations
2024-06-29 07:59:40 -04:00
Vitor Pamplona
3288c35bcd Merge pull request #954 from davotoula/additional-html-entities-for-url-preview
Additional html entities for url preview
2024-06-29 07:59:14 -04:00
David Kaspar
bbe0b86690 Update SE, CZ, DE, PT translations 2024-06-29 08:59:45 +01:00
David Kaspar
afbabfd2d3 Update SE, CZ, DE, PT translations 2024-06-29 08:55:50 +01:00
David Kaspar
d404a09f8e additional html entities 8220, 8230
"8230
2024-06-29 08:30:05 +01:00
David Kaspar
30e9d4b3cd additional html entities x2d, middot 2024-06-29 08:30:05 +01:00
KotlinGeekDev
4238978373 Merge remote-tracking branch 'origin/custom-media-servers' into custom-media-servers 2024-06-29 01:14:20 +01:00
KotlinGeekDev
7f3c7ea69c Remove unnecessary files. 2024-06-29 01:13:16 +01:00
KotlinGeekDev
a4202c96d2 Merge branch 'main' into custom-media-servers 2024-06-29 00:10:08 +00:00
KotlinGeekDev
593f817b6d Try fix(again). 2024-06-29 00:56:08 +01:00
Vitor Pamplona
2230d1467c Fixes kind3 relay lists connecting with Search and Wallet connect types. 2024-06-28 19:39:56 -04:00
KotlinGeekDev
af3a52c75e Try fixing this again. 2024-06-29 00:22:06 +01:00
KotlinGeekDev
f9b1cbc9ba Merge branch 'vitorpamplona:main' into custom-media-servers 2024-06-28 23:13:24 +00:00
Vitor Pamplona
0df37d6694 v0.88.6 2024-06-28 19:03:23 -04:00
KotlinGeekDev
9aa511b398 Refactor: location of files. 2024-06-29 00:02:44 +01:00
KotlinGeekDev
5257b1e1cc Merge branch 'main' into custom-media-servers 2024-06-28 22:50:59 +00:00
Vitor Pamplona
4e9e123666 The AccountDataSource will already authenticate for the ZapPayment Relay. 2024-06-28 18:44:27 -04:00
KotlinGeekDev
e7fec7caab Merge branch 'main' into custom-media-servers
merge main branch
2024-06-28 23:43:46 +01:00
KotlinGeekDev
9a4d829f73 Remove TODO. Make isModified private. 2024-06-28 23:12:04 +01:00
KotlinGeekDev
2423ad6e87 [Squash] Use string resources. Ugly solution to avoid duplicates. 2024-06-28 23:08:08 +01:00
KotlinGeekDev
dc40856bc2 Ugly solution to avoid duplicates. 2024-06-28 23:04:25 +01:00
believethehype
5cbea6999e Merge pull request #2 from believethehype/gallery2
Gallery2
2024-06-28 23:57:32 +02:00
Believethehype
0d18a89177 cleanup 2024-06-28 23:53:40 +02:00
Believethehype
b935f60fcb Merge remote-tracking branch 'upstream/main' into gallery2 2024-06-28 23:40:37 +02:00
Believethehype
45df7713ce remove text preview from images 2024-06-28 23:38:41 +02:00
KotlinGeekDev
b26355097d Refactor renderMediaServerList to use callbacks. Use addServerList. 2024-06-28 22:26:24 +01:00
KotlinGeekDev
feee845d31 Add convenience function for adding default Amethyst list. 2024-06-28 22:19:21 +01:00
KotlinGeekDev
660be862d6 Merge remote-tracking branch 'origin/custom-media-servers' into custom-media-servers 2024-06-28 22:18:05 +01:00
KotlinGeekDev
b5047f213e Move media server list rendering to separate function. Fix padding for rendered items. Rename onDelete to onAddOrDelete() for component reuse. 2024-06-28 22:17:42 +01:00
Vitor Pamplona
1fafb6a503 updates create release script to match new directories 2024-06-28 16:58:45 -04:00
Vitor Pamplona
7c6ec49312 Merge pull request #952 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-28 16:33:38 -04:00
Crowdin Bot
6eb7b45990 New Crowdin translations by GitHub Action 2024-06-28 20:27:40 +00:00
Vitor Pamplona
7c7f704173 v0.88.5 2024-06-28 16:25:44 -04:00
Believethehype
f331398316 gallery is visualised 2024-06-28 21:13:52 +02:00
KotlinGeekDev
6c88029fe3 Mve media server list rendering to separate function. Fix padding for rendered items. Rename onDelete to onAddOrDelete() for component reuse. 2024-06-28 20:01:41 +01:00
KotlinGeekDev
aae7236106 Introduce MediaServerEdit component. 2024-06-28 19:57:57 +01:00
KotlinGeekDev
963a42becf Make first character in derived name uppercase, for ...aesthetics. 2024-06-28 19:56:05 +01:00
Vitor Pamplona
f0044afeab Fixes bug on string resources. 2024-06-28 14:06:08 -04:00
Vitor Pamplona
f9c3ff22e3 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-28 13:28:16 -04:00
Vitor Pamplona
347b82ef7f Fixes when the logged in accounts only include the current account. 2024-06-28 13:28:05 -04:00
Believethehype
a42762de53 alternative attempt.. 2024-06-28 19:14:36 +02:00
Vitor Pamplona
27e5d135bc Merge pull request #949 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-28 11:09:07 -04:00
Believethehype
e073e58241 Update GalleryListEvent.kt 2024-06-28 16:51:09 +02:00
Crowdin Bot
a57ba2550c New Crowdin translations by GitHub Action 2024-06-28 14:43:45 +00:00
Vitor Pamplona
a9cbbf66bd Adds uploading error messages for common HTTP status codes. 2024-06-28 10:40:55 -04:00
Believethehype
91caacd36d added first attempt to add user galleries. can read 10011 events and shows profile tab
What works:
- can load existing 10011 lists and show images in profile tab

What doesn't work:
- gallery view is broken
- can't load notes to click images to get to original note
- adding media to gallery crashes amethyst atm
- no functionality to delete media from gallery yet.
2024-06-28 16:01:56 +02:00
Vitor Pamplona
e181296a91 adds auth.nostr1.com as an option 2024-06-27 20:12:56 -04:00
Vitor Pamplona
6a626c9865 Renders relay lists in the feed. 2024-06-27 20:12:04 -04:00
Vitor Pamplona
7095c475c0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-27 19:03:36 -04:00
Vitor Pamplona
c18df3a157 Fixes extra } when rendering hashtags from Markdown 2024-06-27 19:03:29 -04:00
Vitor Pamplona
aee63de507 Merge pull request #948 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-27 17:03:48 -04:00
Crowdin Bot
2f9438d48d New Crowdin translations by GitHub Action 2024-06-27 21:00:06 +00:00
Vitor Pamplona
700974a5ae Updates testing libs 2024-06-27 16:57:57 -04:00
Vitor Pamplona
15b8a8027c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-27 16:51:39 -04:00
Vitor Pamplona
6c0b22f129 Fixes error when trying to decrypt the PrivateOutboxRelayList event for an account that is not currently active. 2024-06-27 16:51:33 -04:00
Vitor Pamplona
4ee3694292 Fixes error when trying to decrypt the PrivateOutboxRelayList event for an account that is not currently active. 2024-06-27 16:49:05 -04:00
Vitor Pamplona
989099c791 Refactors PlaybackService 2024-06-27 16:47:46 -04:00
Vitor Pamplona
a89440cba2 Moves to non-deterministic signatures 2024-06-27 15:28:09 -04:00
Vitor Pamplona
39c9b4d50f Adjusting default zaps for the Zap the Devs button 2024-06-27 12:19:55 -04:00
KotlinGeekDev
0a6d9a7764 Normalize server URL before adding it, Part 2. 2024-06-27 17:17:49 +01:00
Vitor Pamplona
372eaabb50 Swipe to delete drafts. 2024-06-27 12:13:53 -04:00
KotlinGeekDev
409b63b374 Normalize server URL before adding it. 2024-06-27 16:23:27 +01:00
Vitor Pamplona
ca5c0d6ee4 Adds author picture when writing posts and replies. 2024-06-27 10:00:17 -04:00
Vitor Pamplona
faca3615fc Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-26 17:06:35 -04:00
Vitor Pamplona
7e31401b86 updates versions 2024-06-26 17:06:30 -04:00
Vitor Pamplona
0fad7b313b Fixing alignment of the close button on Chat's reply preview 2024-06-26 16:20:25 -04:00
Vitor Pamplona
930d7d0fed Merge pull request #947 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-26 15:51:10 -04:00
Crowdin Bot
fb59499a0d New Crowdin translations by GitHub Action 2024-06-26 19:45:14 +00:00
Vitor Pamplona
6e256e465f Merge pull request #946 from greenart7c3/main
check for localhost when adding a new relay
2024-06-26 15:44:00 -04:00
greenart7c3
e358387ba1 check for localhost when adding a new relay 2024-06-26 16:33:24 -03:00
KotlinGeekDev
cd9ba9befe Use divider to separate custom and hardcoded server options. Introduce new padding value. 2024-06-26 16:09:56 +01:00
Vitor Pamplona
d6ab376f2d Fixes position of the hash verification icon on NIP-95 images. 2024-06-25 18:26:11 -04:00
Vitor Pamplona
cc6551b11b Removes old highlight rendering functions 2024-06-25 18:19:43 -04:00
Vitor Pamplona
5e417dd890 Fixes the rendering of highlights when no user is present and includes options to render by e tags 2024-06-25 18:19:14 -04:00
Vitor Pamplona
95694ac355 Merge pull request #944 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-25 14:53:29 -04:00
Crowdin Bot
215fba83cf New Crowdin translations by GitHub Action 2024-06-25 18:50:11 +00:00
Vitor Pamplona
a8a2bda9af rearranges the crypto package into separate nips and reduces the amount of circular dependencies. 2024-06-25 12:13:17 -04:00
Vitor Pamplona
79ace7f18c Renames JsonFilter to just Filter and adds a matching function 2024-06-25 10:06:06 -04:00
Vitor Pamplona
c448e75953 Updates all dependencies 2024-06-25 08:44:32 -04:00
Vitor Pamplona
683af698c6 Adjusts the size of the reply button on chats 2024-06-24 20:42:22 -04:00
Vitor Pamplona
fe19ddb259 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-24 19:22:23 -04:00
Vitor Pamplona
4c3c7e7cba Fixes not showing relay icons when sending messages with Amber. 2024-06-24 19:22:15 -04:00
Vitor Pamplona
f5e08e47a8 Merge pull request #942 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-24 19:14:51 -04:00
Crowdin Bot
782df3bcb5 New Crowdin translations by GitHub Action 2024-06-24 23:12:34 +00:00
Vitor Pamplona
1b2f7e21f6 Makes sure to download the updated list of relays when looking at a user. 2024-06-24 18:52:04 -04:00
Vitor Pamplona
4943e1c7ed Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-24 18:07:33 -04:00
Vitor Pamplona
0f066d0817 Saves a copy of the NIP65 and NIP17 relay lists locally 2024-06-24 18:06:53 -04:00
Vitor Pamplona
839cadea35 Saves a copy of the NIP65 and NIP17 relay lists locally 2024-06-24 17:56:24 -04:00
Vitor Pamplona
8cef7bcb75 Logging as a Relay error message when the relay does not accept an event. 2024-06-24 17:55:31 -04:00
Vitor Pamplona
62ac05ae81 Merge pull request #941 from vitorpamplona/changes-app-dir-to-amethyst
changes app directory to amethyst
2024-06-24 17:53:11 -04:00
Vitor Pamplona
4609d6e343 changes app directory to amethyst 2024-06-24 14:13:55 -04:00
Vitor Pamplona
e70e8f8437 Merge pull request #940 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-24 13:39:31 -04:00
Crowdin Bot
1b57c5636d New Crowdin translations by GitHub Action 2024-06-24 17:36:59 +00:00
Vitor Pamplona
9065f8eea2 Merge pull request #939 from greenart7c3/new_package_relay
Moves relay classes to a new package
2024-06-24 13:35:41 -04:00
greenart7c3
8c3e87339a remove commented code 2024-06-24 14:03:01 -03:00
greenart7c3
9ee2452430 Fix build 2024-06-24 11:32:03 -03:00
greenart7c3
266dabdd7a fix shared prefs key 2024-06-24 11:24:13 -03:00
greenart7c3
21898482de Fix merge 2024-06-24 11:22:41 -03:00
greenart7c3
c281654e97 Merge branch 'main' into new_package_relay 2024-06-24 11:16:03 -03:00
greenart7c3
c0ee8e2d61 fix proguard rules 2024-06-24 11:10:13 -03:00
greenart7c3
66e4021d7b Move all relay and datasource to ammolite package 2024-06-24 10:52:34 -03:00
greenart7c3
b00781ec32 check if HttpClientManager appears in the jar 2024-06-24 09:19:14 -03:00
greenart7c3
f08a41d7a5 proguard 2024-06-24 08:33:57 -03:00
greenart7c3
6eb0f153fa fix build 2024-06-24 07:36:11 -03:00
greenart7c3
a6bc40515b Move httpclient to another package 2024-06-24 07:13:06 -03:00
KotlinGeekDev
67ef25f538 Merge branch 'vitorpamplona:main' into custom-media-servers 2024-06-22 18:28:55 +00:00
Vitor Pamplona
c37adfc6a2 v0.88.4 2024-06-21 19:53:49 -04:00
Vitor Pamplona
d6764e08fd Bigger reaction panel 2024-06-21 19:53:02 -04:00
Vitor Pamplona
ed80511cb6 v0.88.3 2024-06-21 19:02:10 -04:00
Vitor Pamplona
08da97e36d Updates version of FirebaseBom 2024-06-21 18:39:27 -04:00
Vitor Pamplona
a14ab59e78 Converts LiveData for Content-Sensitivity and Transitive Hidden Users into Flow to avoid locking the main thread while scrolling.
Requests flows on the Default thread.
2024-06-21 18:38:31 -04:00
Vitor Pamplona
f7c60b3745 Making Metered Wifi behave like Mobile for heavy content. 2024-06-21 17:04:00 -04:00
Vitor Pamplona
f8a77d634c Adds a check to see if the receiver has NIP-17 relays setup and if so activates NIP-17 message. 2024-06-21 15:05:41 -04:00
Vitor Pamplona
fdd1bb9810 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-21 13:35:25 -04:00
Vitor Pamplona
8a1aefeda7 Adds an option to change reactions from the popup 2024-06-21 13:31:48 -04:00
Vitor Pamplona
64813a4623 Merge pull request #938 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-21 13:25:56 -04:00
Crowdin Bot
e86f0c54f3 New Crowdin translations by GitHub Action 2024-06-21 17:20:26 +00:00
Vitor Pamplona
d2f06f5310 Removing the X to delete reaction in favor of a Selected background 2024-06-21 13:18:48 -04:00
Vitor Pamplona
7a22b3df71 Realigning reactions 2024-06-21 12:51:16 -04:00
Vitor Pamplona
97671c570c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-21 12:49:47 -04:00
Vitor Pamplona
accad0c77a Adds a Share option with NIP-19 nprofile icon on the User Profile screen
Moves to NProfile instead of NPub to cite users.
Adds Npub or NIP-05 to the QR Screen
2024-06-21 12:49:35 -04:00
KotlinGeekDev
a19deb2076 Merge branch 'vitorpamplona:main' into custom-media-servers 2024-06-21 13:55:16 +00:00
KotlinGeekDev
bba7dba17c Introduce MediaServersListView and add it to Drawer. 2024-06-21 14:52:08 +01:00
Vitor Pamplona
985274980a Merge pull request #937 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-21 09:29:05 -04:00
Crowdin Bot
4470d01229 New Crowdin translations by GitHub Action 2024-06-21 13:26:29 +00:00
Vitor Pamplona
35a9c07636 Adds pubkey to the host filter in live events. 2024-06-21 09:24:01 -04:00
Vitor Pamplona
3fa5877840 Fixes formatting 2024-06-20 17:39:20 -04:00
Vitor Pamplona
56bbfe60bb Merge pull request #923 from VASHvic/redesign_emoji_selector
redesign emoji selector
2024-06-20 17:36:23 -04:00
Vitor Pamplona
d2f604960e Merge branch 'main' into redesign_emoji_selector 2024-06-20 17:35:41 -04:00
Vitor Pamplona
76a4236b70 Merge pull request #933 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-20 16:49:07 -04:00
Crowdin Bot
fece7411c9 New Crowdin translations by GitHub Action 2024-06-20 20:32:46 +00:00
Vitor Pamplona
0e1e379c7d v0.88.2 2024-06-20 16:30:33 -04:00
Vitor Pamplona
b39ac214a5 Limits the amount of zaps on the user profile 2024-06-20 16:30:12 -04:00
Vitor Pamplona
9e9e0667cd Reverts old migration of accounts due the fact that some people were still in the old model 2024-06-20 16:29:48 -04:00
Vitor Pamplona
82f99faec8 Fixes name instead of uri 2024-06-20 12:22:07 -04:00
Vitor Pamplona
d07a767fa5 Merge pull request #931 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-20 09:55:47 -04:00
Crowdin Bot
ec19038e5c New Crowdin translations by GitHub Action 2024-06-20 13:50:41 +00:00
Vitor Pamplona
eaa09055fc v0.88.1 2024-06-20 09:48:10 -04:00
Vitor Pamplona
0d956c2f39 Removes the need to exclude byte-buddy due to jackson's earlier version bug. 2024-06-20 09:47:43 -04:00
Vitor Pamplona
3a4e2e9623 Don't use DM kinds to get information about other logged-in accounts 2024-06-20 09:47:11 -04:00
Vitor Pamplona
d33befe1ad Removes account migration and unused keys for shared preferences. 2024-06-20 09:42:10 -04:00
KotlinGeekDev
b6fd067d48 Merge branch 'vitorpamplona:main' into custom-media-servers 2024-06-20 12:46:25 +00:00
Vitor Pamplona
d28f20519b v0.88.0 2024-06-19 16:34:01 -04:00
Vitor Pamplona
239f390e7c Fixes Fdroid tests 2024-06-19 15:43:10 -04:00
Vitor Pamplona
857d16582a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-19 15:40:23 -04:00
Vitor Pamplona
aae47aa2e1 Fixing Options Button 2024-06-19 15:40:14 -04:00
Vitor Pamplona
f115111c49 Centralizes stringResource calls to cache them. 2024-06-19 15:12:07 -04:00
Vitor Pamplona
83058500d6 Merge pull request #930 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-19 13:08:48 -04:00
Crowdin Bot
2cb1e70170 New Crowdin translations by GitHub Action 2024-06-19 16:38:15 +00:00
Vitor Pamplona
73513713eb Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-19 12:36:29 -04:00
Vitor Pamplona
7bf5c03637 removing the state needs for rendering text. 2024-06-19 12:36:22 -04:00
Vitor Pamplona
d39d1239f6 Moves uncited hashtag parsing to a thread 2024-06-19 12:36:09 -04:00
Vitor Pamplona
a0271a2982 Moves ClickableBox to it's own file 2024-06-19 12:35:25 -04:00
Vitor Pamplona
2484927c22 Merge pull request #929 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-19 08:54:42 -04:00
Crowdin Bot
bff662b85d New Crowdin translations by GitHub Action 2024-06-19 12:53:07 +00:00
Vitor Pamplona
f293f6c169 Merge pull request #928 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-19 08:52:07 -04:00
Vitor Pamplona
e9456af15c Fixes the inconsistency of button animations in the reaction row 2024-06-19 08:48:41 -04:00
Crowdin Bot
157fad6a84 New Crowdin translations by GitHub Action 2024-06-19 12:48:33 +00:00
Vitor Pamplona
6c137a5174 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-19 08:39:09 -04:00
Vitor Pamplona
6550bdefd5 Thinner Zap button 2024-06-18 19:34:43 -04:00
Vitor Pamplona
614e9ff661 Correctly tints the search button 2024-06-18 19:33:22 -04:00
Vitor Pamplona
5da9e430b0 Get's rid of the view count. 2024-06-18 19:33:04 -04:00
Vitor Pamplona
fbd32bf426 Merge pull request #927 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-18 19:07:45 -04:00
Vitor Pamplona
f28d96d303 removes remember from LoadUrlPreview 2024-06-18 19:00:26 -04:00
Vitor Pamplona
442322f708 Removes the alpha channel for the performance placeholder painter 2024-06-18 19:00:10 -04:00
Vitor Pamplona
a463e8889c Adds an outlined Zap icon to match the other icons 2024-06-18 18:59:41 -04:00
Crowdin Bot
224e54cc72 New Crowdin translations by GitHub Action 2024-06-18 22:23:30 +00:00
Vitor Pamplona
a23cbd1a38 Formatting 2024-06-18 18:21:27 -04:00
Vitor Pamplona
548dd9e34f Makes sure the isAcceptable runs on IO thread 2024-06-18 18:20:58 -04:00
Vitor Pamplona
c27f4a62b6 Switches Social Icons to faster SVG versions 2024-06-18 18:15:02 -04:00
Vitor Pamplona
e18945b378 Adds performance mode for older devices. 2024-06-18 09:13:48 -04:00
Vitor Pamplona
08869ac350 removes derived states and some remember functions for performance. 2024-06-18 09:11:32 -04:00
Vitor Pamplona
5d5c3ae3e3 Faster calculation of uncited hashtags. 2024-06-17 17:22:39 -04:00
Vitor Pamplona
0fa6dce42c Fixes giftwrap date mismatch 2024-06-17 16:20:40 -04:00
Vitor Pamplona
aad95595eb Quick Refactoring of the Author UI components. 2024-06-17 14:37:31 -04:00
Vitor Pamplona
24fede3768 Merge pull request #925 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-16 19:43:00 -04:00
Crowdin Bot
a8b6118e2d New Crowdin translations by GitHub Action 2024-06-16 23:37:55 +00:00
Vitor Pamplona
719be8469f Fix test cases 2024-06-16 19:36:41 -04:00
Vitor Pamplona
1496052bba Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-15 12:36:12 -04:00
Vitor Pamplona
545df83177 Simple refactoring 2024-06-15 12:34:03 -04:00
Vitor Pamplona
80d506be37 Allows login with NIP-05 address 2024-06-15 12:33:43 -04:00
Vitor Pamplona
ae0ae413e6 Fixes Go Button on new user screen 2024-06-15 12:33:06 -04:00
VASH
43339b8d36 redesign emoji selector 2024-06-15 18:27:05 +02:00
Vitor Pamplona
6a9f9e3f81 Merge pull request #922 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-15 11:36:25 -04:00
Crowdin Bot
010402ebd5 New Crowdin translations by GitHub Action 2024-06-15 15:31:05 +00:00
Vitor Pamplona
c07a0065a1 Upgrades Compose, Lifecycle and Fragments 2024-06-15 11:28:20 -04:00
Vitor Pamplona
704af55636 Merge pull request #921 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-15 09:48:42 -04:00
Crowdin Bot
475e1c6fe0 New Crowdin translations by GitHub Action 2024-06-15 13:47:01 +00:00
Vitor Pamplona
21c397b5e0 Merge pull request #920 from davotoula/915-NIP05-verification-follows-redirects-against-specification
Set redirects to false for httpclient used during nip05
2024-06-15 09:45:49 -04:00
David Kaspar
29a002e451 Set redirects to false for httpclient used during nip05 2024-06-15 09:51:05 +02:00
Vitor Pamplona
035affadb9 Merge pull request #919 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-14 12:32:49 -04:00
Vitor Pamplona
79e3142f2d Merge branch 'main' into l10n_crowdin_translations 2024-06-14 12:32:41 -04:00
Crowdin Bot
bf880983a7 New Crowdin translations by GitHub Action 2024-06-14 16:30:32 +00:00
Vitor Pamplona
115746353e Merge pull request #917 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-14 12:29:22 -04:00
Vitor Pamplona
2fd4521e1d Merge pull request #918 from davotoula/correct_spelling
Correct the spelling of a few translation keywords
2024-06-14 12:29:05 -04:00
Crowdin Bot
3a8a9e296e New Crowdin translations by GitHub Action 2024-06-14 16:01:39 +00:00
Vitor Pamplona
86ebb78823 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-14 11:59:24 -04:00
Vitor Pamplona
bda0e37767 Merge pull request #916 from greenart7c3/main
Fix hidden notes when hidden words is empty
2024-06-14 11:59:07 -04:00
David Kaspar
900cc03e4f correct spelling 2024-06-14 17:55:41 +02:00
David Kaspar
013357ae6b correct spelling 2024-06-14 17:51:33 +02:00
David Kaspar
a58a67d404 correct spelling 2024-06-14 17:50:28 +02:00
Vitor Pamplona
2ded31dd55 Improves the speed of RobohashAsync by avoiding remember and base64 state. 2024-06-14 11:42:27 -04:00
greenart7c3
dad455f9a0 Fix hidden notes when hidden words if empty 2024-06-14 12:35:17 -03:00
Vitor Pamplona
8b87f04268 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-14 09:34:59 -04:00
Vitor Pamplona
627c8d1663 Moves to AGP 8.5 and Gradle 8.7 2024-06-14 09:34:48 -04:00
Vitor Pamplona
287a64be11 Merge pull request #914 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-14 08:39:45 -04:00
Crowdin Bot
8f24eeaf4a New Crowdin translations by GitHub Action 2024-06-14 12:33:24 +00:00
Vitor Pamplona
39efc2ef9b Adjusting zap calculations 2024-06-14 08:31:06 -04:00
Vitor Pamplona
52a24d5223 Removing unnecessary modifiers and remember calls 2024-06-14 08:22:38 -04:00
Vitor Pamplona
ca8d32b837 Moves chatroom user group away from immutable sets 2024-06-14 08:22:24 -04:00
Vitor Pamplona
f508f45e16 Fixes Url Preview Crop when the image is too small or to big for the preview card. 2024-06-14 08:16:51 -04:00
Vitor Pamplona
d7a22f609a Small optimizations: removing animations, avoiding remembers 2024-06-13 19:08:20 -04:00
Vitor Pamplona
2503cbaeac - Unifies All Badge rendering
- Constraints badge layouts on Notes
- Creates a flow cache to speed up Note relay layout
- Speeds up NoteQuickActionMenus
- Optimizes NIP-11 pings
- Better error messages when NIP-11 fails
2024-06-13 16:50:19 -04:00
Vitor Pamplona
6bc305dbe6 Refactoring 2024-06-13 16:38:48 -04:00
Vitor Pamplona
26cbc0754e Creates a Loading state to avoid requesting NIP-11 documents twice. 2024-06-13 13:31:51 -04:00
KotlinGeekDev
ed5d7335a0 Simplify addServer() and removeServer(). Introduce saveFileServers() for saving changes, and broadcasting them. 2024-06-13 11:08:44 +01:00
KotlinGeekDev
76e3b5abe7 Add rudimentary implementations for addServer() and removeServer(). 2024-06-13 02:19:18 +01:00
Vitor Pamplona
45445c03e5 - Merges Hidden and Reporting flows
- Removes Report Live data
- Refactors Full Bleed design
- Unifies Hidden and Report checks between the Video Feed, the Full Bleed Design and the Card layout.
2024-06-12 16:54:39 -04:00
Vitor Pamplona
0d00f2f80a No need to remember createdAts 2024-06-12 16:49:10 -04:00
Vitor Pamplona
27f437020e Merge pull request #911 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-12 15:10:24 -04:00
Crowdin Bot
2345c45ebf New Crowdin translations by GitHub Action 2024-06-12 19:07:56 +00:00
Vitor Pamplona
42408978c4 - Moves away from persistent collections (slower)
- Adds a hashcode cash for spam and blocked user public keys
- Adds a cache for isHidden
- Moves isHidden composable from LiveData to Flow
2024-06-12 15:05:46 -04:00
Vitor Pamplona
a716d13c69 Slightly faster is content sensitive method 2024-06-12 14:56:51 -04:00
Vitor Pamplona
77393b433e formatting 2024-06-12 12:41:31 -04:00
Vitor Pamplona
a7c06b46a7 Use navigateUp instead of popBackStack to avoid closing the app on double clicks 2024-06-12 12:40:43 -04:00
Vitor Pamplona
1de55bb16f Migrates to AGP 8.4.2 2024-06-12 08:01:54 -04:00
Vitor Pamplona
20520bbce4 Fixes the centered url on videos without active playback 2024-06-11 19:15:02 -04:00
Vitor Pamplona
42e11d239d Fixes the padding of the reaction row on quotes 2024-06-11 18:51:47 -04:00
Vitor Pamplona
957ad1e0db Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-11 18:20:35 -04:00
Vitor Pamplona
3123685f88 Tries to solve notification dots active for hidden posts. 2024-06-11 18:20:27 -04:00
Vitor Pamplona
31e8782b1a adds a native map for tags in the Event class to avoid creating sublists before mapping tags 2024-06-11 18:19:52 -04:00
Vitor Pamplona
cafad6a003 Refactoring 2024-06-11 18:19:07 -04:00
Vitor Pamplona
b785e0d505 Removes old Robohash 2024-06-11 18:17:51 -04:00
Vitor Pamplona
226153a359 Merge pull request #910 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-11 15:23:06 -04:00
Crowdin Bot
b70aa54aa3 New Crowdin translations by GitHub Action 2024-06-11 19:07:46 +00:00
Vitor Pamplona
fc96d89a44 Reducing double launch or co-routines 2024-06-11 15:05:57 -04:00
Vitor Pamplona
dd71b931fc Reducing memory recreation by only setting the current subscription as after EOSE 2024-06-11 15:05:38 -04:00
Vitor Pamplona
d6abdf16e0 Increasing the download limit for reactions to event 2024-06-11 15:05:11 -04:00
Vitor Pamplona
66e18e17c5 Adds relays to zap request:
- sender, receiver, author
2024-06-11 13:32:13 -04:00
Vitor Pamplona
7b2da8312d Adds two extra nevent test cases. 2024-06-11 13:31:45 -04:00
KotlinGeekDev
353a30efe4 Introduce MediaServersViewModel. Basic things for now. 2024-06-11 16:46:49 +01:00
Vitor Pamplona
c71c87e946 Better rounding for zaps 2024-06-11 11:30:55 -04:00
Vitor Pamplona
9ca6151313 - Adds the NIP-65 relay to zap split tag instead of kind3 relays.
- Rounds percentages to the 2 decimals.
2024-06-11 11:23:20 -04:00
Vitor Pamplona
b322efc286 Fixes padding of the zap raiser 2024-06-11 11:22:21 -04:00
Vitor Pamplona
b775c1bf74 Avoids showing error message if devs have removed their lnadress on the Zap the Devs card. 2024-06-11 11:22:09 -04:00
Vitor Pamplona
a6b2671b1c Fixes padding of the zap split section on the master note 2024-06-11 11:20:44 -04:00
Vitor Pamplona
abc5d0ea2f Fixes border of the zap card 2024-06-11 10:46:15 -04:00
Vitor Pamplona
3c25d91b98 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-11 10:36:52 -04:00
Vitor Pamplona
3d8aace55a Merge pull request #909 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-11 10:36:44 -04:00
Vitor Pamplona
bd6202f8e1 - Adds support for AVIF images
- Removes gray border in image urls that couldn't be loaded.
2024-06-11 10:25:11 -04:00
Crowdin Bot
c1875e1389 New Crowdin translations by GitHub Action 2024-06-11 14:03:00 +00:00
Vitor Pamplona
3a63c1d1ab Fixes alignment of reactions 2024-06-11 10:00:26 -04:00
Vitor Pamplona
609faf245a Reverts save button 2024-06-10 20:01:37 -04:00
Vitor Pamplona
bfd28c4b44 Reverts save button 2024-06-10 20:01:11 -04:00
Vitor Pamplona
70a8488f12 Merge pull request #907 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-10 19:59:47 -04:00
Crowdin Bot
cda6e4722c New Crowdin translations by GitHub Action 2024-06-10 23:54:07 +00:00
Vitor Pamplona
c4ecf85618 Reverting the spotlight on the Save button 2024-06-10 19:51:52 -04:00
Vitor Pamplona
6ed1f9d369 Merge pull request #906 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-10 09:59:32 -04:00
Crowdin Bot
aea765ec54 New Crowdin translations by GitHub Action 2024-06-10 13:52:45 +00:00
Vitor Pamplona
165c3bb852 Fixes not centered Blank Notes 2024-06-10 09:50:37 -04:00
Vitor Pamplona
2e9683491f Fixes scrollable drawer for all screen sizes 2024-06-10 09:50:24 -04:00
Vitor Pamplona
eea8b99622 v0.87.6 2024-06-07 20:16:52 -04:00
Vitor Pamplona
0db6a0d27b Fixes relay drops and difficulty in connecting.
Refactors the use of relay objects to avoid creating full state relay connections only once
2024-06-07 20:13:15 -04:00
Vitor Pamplona
723e575b4b Fixes the LazyColumn for Hashtags on Profile 2024-06-07 19:28:20 -04:00
Vitor Pamplona
b182d409b6 Fixes benchmark tests 2024-06-07 18:50:32 -04:00
Vitor Pamplona
043e37030e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-07 13:46:11 -04:00
Vitor Pamplona
8d730040b8 Improved description for public inbox 2024-06-07 13:46:04 -04:00
Vitor Pamplona
794a300c74 Merge pull request #904 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-07 12:42:58 -04:00
Crowdin Bot
91fd490ab7 New Crowdin translations by GitHub Action 2024-06-07 15:54:44 +00:00
Vitor Pamplona
0e1de03db8 Only updates video position when the video is ready to play 2024-06-07 11:52:39 -04:00
Vitor Pamplona
7906a94ca0 Moves the video release to a thread without damaging ON_PAUSE, ON_RELEASE order. 2024-06-07 11:42:15 -04:00
Vitor Pamplona
6ce019a86a Larger search limits for profiles 2024-06-07 10:37:13 -04:00
Vitor Pamplona
d8939779e8 Improves UI buttons of the Image Dialog 2024-06-07 10:36:56 -04:00
Vitor Pamplona
313d87a58b Removes debug marker 2024-06-06 18:43:11 -04:00
Vitor Pamplona
057e61bfe1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-06 18:39:48 -04:00
Vitor Pamplona
c94c882c3a restructures video and image views to avoid buttons getting on top of one another. 2024-06-06 18:39:39 -04:00
Vitor Pamplona
673f632563 Refactoring 2024-06-06 12:43:59 -04:00
Vitor Pamplona
1a56b2bf68 Log messages when saving to disk. 2024-06-05 18:21:20 -04:00
Vitor Pamplona
2e9964e0e8 Merge pull request #903 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-05 16:37:28 -04:00
Crowdin Bot
b80eb32991 New Crowdin translations by GitHub Action 2024-06-05 20:30:27 +00:00
Vitor Pamplona
ad281c7607 BugFix crashing with old keyboards: Forces transformations to have middle positions before the last cursor position. 2024-06-05 16:28:44 -04:00
Vitor Pamplona
3c1a918b2d Moving attestations local cache to a flow 2024-06-05 13:24:31 -04:00
Vitor Pamplona
53e6677558 Adds Hashtag search and display to Reposted Notes. 2024-06-05 11:58:02 -04:00
Vitor Pamplona
b8deb34544 Avoid using DM relays to get chats 2024-06-05 09:34:03 -04:00
Vitor Pamplona
d9cffa87ef When broadcasting privately, avoids keeping the connection open for other activities. 2024-06-05 08:33:11 -04:00
Vitor Pamplona
ae418f9d45 v0.87.5 2024-06-04 18:46:45 -04:00
Vitor Pamplona
d10b4c6bde - Re-normalizes all relays urls before connecting to reduce duplications
- Normalizes all new relays in the edit screens.
2024-06-04 18:30:01 -04:00
Vitor Pamplona
f1e516662c Adds flare.pub videos to the media tab on Amethyst 2024-06-04 17:12:14 -04:00
Vitor Pamplona
1bf8165641 Improving the detection of image types on the Media Feed. 2024-06-04 16:38:31 -04:00
Vitor Pamplona
807af3b613 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-04 16:01:50 -04:00
Vitor Pamplona
e36ec0a0a3 removes extra char on xml 2024-06-04 16:01:37 -04:00
Vitor Pamplona
3172b69271 Merge pull request #902 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-04 15:55:27 -04:00
Crowdin Bot
81ee13bebc New Crowdin translations by GitHub Action 2024-06-04 19:47:45 +00:00
Vitor Pamplona
a6a8138c7f - Performance Improvements: Simplifies intrinsic size calculations for Image Previews and Videos
- Improves rendering of chat messages
- Fixes the jittering from resizing Videos and Images during loading.
- Fixes video cut off when loading
- Fixes blurhash covering videos in landscape
- Fixes landscape video centralization
- Fixes chat using Two Pane layout for landscape and regular for portrait
2024-06-04 15:45:59 -04:00
Vitor Pamplona
16142f61b8 normalization refactoring 2024-06-04 10:54:06 -04:00
Vitor Pamplona
7193228dd2 Fixes size of the incognitoIcon on chat messages 2024-06-03 19:32:46 -04:00
Vitor Pamplona
29b8f6916b v0.87.4 2024-06-03 17:29:43 -04:00
Vitor Pamplona
a6519d57c6 Passes the video note uri so that when the user clicks in the video popup the app brings it back to the playing screen without breaking the video. 2024-06-03 17:26:40 -04:00
Vitor Pamplona
2b2ee5bbfc Avoids using Video Cache for live streams. 2024-06-03 17:18:38 -04:00
Vitor Pamplona
62a6b7b7e0 Fixes video stream position for live streams. 2024-06-03 17:18:17 -04:00
Vitor Pamplona
5e0a1f9de1 Moves SimpleCache creation to the IO thread. 2024-06-03 17:17:26 -04:00
Vitor Pamplona
571b1bc9cf Merge pull request #900 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 14:27:47 -04:00
Crowdin Bot
a3e76225e6 New Crowdin translations by GitHub Action 2024-06-03 18:26:14 +00:00
Vitor Pamplona
4388ceb529 v0.87.3 2024-06-03 14:24:28 -04:00
Vitor Pamplona
5d4fa0d732 Adds the incoming relays to the request. 2024-06-03 14:15:12 -04:00
Vitor Pamplona
ed6825de97 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-03 14:04:46 -04:00
Vitor Pamplona
10f623b6b3 Forces URLs to be single line. 2024-06-03 14:04:38 -04:00
Vitor Pamplona
467e5c0e4a Fixes bug that every socket is null 2024-06-03 14:04:29 -04:00
Vitor Pamplona
09d2e9593e Adds error messages to relay information dialog 2024-06-03 14:03:58 -04:00
Vitor Pamplona
cd20c0567b Merge pull request #899 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 13:27:05 -04:00
Vitor Pamplona
db15f5e2a6 Uses the user to avoid Java removing the variable due to lack of usage. 2024-06-03 13:25:56 -04:00
Crowdin Bot
18d5c7e3f4 New Crowdin translations by GitHub Action 2024-06-03 16:08:05 +00:00
Vitor Pamplona
d42ae7c9f7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-03 12:06:38 -04:00
Vitor Pamplona
2b741b62e8 Merge pull request #898 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 12:06:14 -04:00
Vitor Pamplona
4e1c63d36a allows more replies to filter when using DVMs 2024-06-03 12:05:58 -04:00
Vitor Pamplona
95797171d7 Fixes observer of authors 2024-06-03 12:05:46 -04:00
Crowdin Bot
395ef9695e New Crowdin translations by GitHub Action 2024-06-03 15:49:13 +00:00
Vitor Pamplona
ad1c7088c7 Loads the DVM's NIP65 records. 2024-06-03 11:47:39 -04:00
Vitor Pamplona
eda4569b64 Only shows debug relay writes in DEBUG 2024-06-03 11:47:20 -04:00
Vitor Pamplona
fb5f1e738b Adds a user and max result params to the DVM request. 2024-06-03 11:47:00 -04:00
Vitor Pamplona
e3cca6c691 Pulls NIP-65 relays even in the first lookup for the user. 2024-06-03 11:45:57 -04:00
Vitor Pamplona
ce2b8dfa11 Sends the DVM request to the NIP65 Inbox relays. 2024-06-03 11:15:36 -04:00
Vitor Pamplona
b5faf6ffc5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-03 11:15:07 -04:00
Vitor Pamplona
4ee157f27f Merge pull request #897 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 11:14:50 -04:00
Vitor Pamplona
ad8c04b79a Loads user's relays on scrolling. 2024-06-03 11:14:33 -04:00
Crowdin Bot
c0e6273954 New Crowdin translations by GitHub Action 2024-06-03 15:04:13 +00:00
Vitor Pamplona
aebade21a6 Don't display zap splits when the post is short. 2024-06-03 11:02:23 -04:00
Vitor Pamplona
bda24c0101 Separates the Account's relay info to the UI component 2024-06-03 11:01:34 -04:00
Vitor Pamplona
3d0b461550 Moves Relay Stats to it's own cache.
Refactors the isLocalRelay function
Removes unnecessary onError methods from the Relay class
Moves byte counts from Int to Long
2024-06-03 10:42:00 -04:00
Vitor Pamplona
5900e6c49f Merge pull request #896 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 08:45:33 -04:00
Crowdin Bot
f29fe3ced5 New Crowdin translations by GitHub Action 2024-06-03 12:38:46 +00:00
Vitor Pamplona
bf17cc8f03 Updating Firebase and Androidx JUnit 2024-06-03 08:36:45 -04:00
Vitor Pamplona
a8d10c22d4 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-01 16:14:47 -04:00
Vitor Pamplona
5492ebf43f Merge pull request #893 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 16:13:05 -04:00
Vitor Pamplona
0c48366d56 v0.80.2 2024-06-01 16:12:49 -04:00
Crowdin Bot
d66a027270 New Crowdin translations by GitHub Action 2024-06-01 19:33:36 +00:00
Vitor Pamplona
f1834af506 Moves the filter for no subscriptions to the DAL 2024-06-01 15:31:43 -04:00
Vitor Pamplona
f26fa71e49 improves the speed of the kind filter for DVMs 2024-06-01 15:20:26 -04:00
Vitor Pamplona
4faee68174 Filters by supported kinds in the DVM view. 2024-06-01 15:16:35 -04:00
Vitor Pamplona
2326e66382 Solves race conditions when connecting to relays. 2024-06-01 15:16:18 -04:00
Vitor Pamplona
bf71365f51 Merge pull request #892 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 13:18:30 -04:00
Crowdin Bot
f45e8d491c New Crowdin translations by GitHub Action 2024-06-01 17:17:09 +00:00
Vitor Pamplona
5a476cab32 v0.87.0 2024-06-01 13:15:27 -04:00
Vitor Pamplona
b5b0a19df5 Fixes the disappearance of the notification icon 2024-06-01 13:14:30 -04:00
Vitor Pamplona
85d26dee08 Merge pull request #891 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 10:58:27 -04:00
Crowdin Bot
476da07d3d New Crowdin translations by GitHub Action 2024-06-01 14:55:45 +00:00
Vitor Pamplona
3b7344e894 v0.87.0 2024-06-01 10:53:39 -04:00
Vitor Pamplona
d307bb0e49 Fixes missing relays certain internal lists 2024-05-31 19:47:40 -04:00
Vitor Pamplona
1bbcb97d54 Fixes read status between others and the logged in user 2024-05-31 16:54:18 -04:00
Vitor Pamplona
3a9637ccb9 Optimizes Blurhash generation 2024-05-31 14:24:55 -04:00
Vitor Pamplona
071da53a6a Waits 500ms before restarting all connections. 2024-05-30 17:11:11 -04:00
Vitor Pamplona
9d190f93bb deactivate write for search relays (they are read only) 2024-05-30 17:10:56 -04:00
Vitor Pamplona
7c65a93896 - Only updates lists if they were changed.
- Refactoring RelayLists
2024-05-30 17:10:38 -04:00
Vitor Pamplona
d9b96d2fd5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-30 16:20:00 -04:00
Vitor Pamplona
3333fdc237 Layout improvements for the relay screen 2024-05-30 16:19:52 -04:00
Vitor Pamplona
e178ff988d Adds NIP-50 requirements to the Search explainer. 2024-05-30 16:19:39 -04:00
Vitor Pamplona
df649445d0 Merge pull request #890 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 16:02:37 -04:00
Vitor Pamplona
a38346c7d3 Adds private outbox relays to save Draft events. 2024-05-30 16:02:21 -04:00
Crowdin Bot
71da587426 New Crowdin translations by GitHub Action 2024-05-30 19:58:38 +00:00
Vitor Pamplona
6ecb5ecc47 Merge pull request #889 from believethehype/main
show error messages if dvm status returns error
2024-05-30 15:57:23 -04:00
Believethehype
b2a19efcd0 show error messages, if dvm status returns error 2024-05-30 20:12:06 +02:00
Vitor Pamplona
1123586ef6 Uses DM Relay List to activate nip17 messages in chats. 2024-05-30 14:00:20 -04:00
Vitor Pamplona
ab037e61aa adds local relays to the connecting set 2024-05-30 13:51:31 -04:00
Vitor Pamplona
4a43b57f79 Uses NIP-65 preferably for zap requests. 2024-05-30 13:51:14 -04:00
Vitor Pamplona
f81c83326c Relay screen padding adjustments 2024-05-30 13:33:11 -04:00
Vitor Pamplona
fdd1dd1855 Adds a title to the relay settings screen 2024-05-30 12:44:29 -04:00
Vitor Pamplona
9f3d4db9dd Adds Local Relays to the relay list screen, saving locally in the device. 2024-05-30 12:35:24 -04:00
Vitor Pamplona
c23a9f8ad6 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-30 11:21:47 -04:00
Vitor Pamplona
36b0c8acb5 Fixing crash when calling localcache on the main thread. 2024-05-30 11:21:39 -04:00
Vitor Pamplona
dc12789166 Merge pull request #888 from KotlinGeekDev/unifiedpush-updates
UnifiedPush library updates
2024-05-30 11:20:38 -04:00
KotlinGeekDev
942b0fa531 Merge branch 'vitorpamplona:main' into unifiedpush-updates 2024-05-30 15:07:48 +00:00
KotlinGeekDev
8ae7f31c3d Refactor getSavedDistributor(). It uses UnifiedPush.getSavedDistributor underneath to preserve pre-API change behaviour. 2024-05-30 16:04:30 +01:00
KotlinGeekDev
bc1bbc2f70 Update to up-connector to v2.3.1. The JVM toolchain and Kotlin downgrade in 2.4.0 may not be good for Amethyst now. Will upgrade when everything has checked out. 2024-05-30 16:01:22 +01:00
Vitor Pamplona
632c78792a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-30 09:55:00 -04:00
Vitor Pamplona
e2a719164f Migrates Kotlin and dependencies to the latest version 2024-05-30 09:48:45 -04:00
Vitor Pamplona
ff94f45d04 Enabling download and creation of NIP-96 file servers. 2024-05-30 09:38:33 -04:00
Vitor Pamplona
7e1b1c7d2c No need for experimental tag anymore. 2024-05-30 09:37:15 -04:00
Vitor Pamplona
f6f9d82665 Merge pull request #887 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 08:41:51 -04:00
Crowdin Bot
3638059c23 New Crowdin translations by GitHub Action 2024-05-30 12:41:41 +00:00
Vitor Pamplona
a781e733f2 improving the filter for the request id of the cached response. 2024-05-30 08:39:23 -04:00
Vitor Pamplona
56d6376efc Merge pull request #886 from believethehype/main
Check if NIP90 Result exists before relying on Cache
2024-05-30 08:34:59 -04:00
Vitor Pamplona
3ac9d3a4f7 Merge pull request #885 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 08:33:49 -04:00
Believethehype
521da9a774 check if NIP90 Result list exists before relying on Cache 2024-05-30 14:23:24 +02:00
Crowdin Bot
18ebdaf96d New Crowdin translations by GitHub Action 2024-05-30 10:50:40 +00:00
Vitor Pamplona
19d394a383 Merge pull request #884 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 06:49:20 -04:00
Crowdin Bot
b198b4c081 New Crowdin translations by GitHub Action 2024-05-30 10:47:55 +00:00
Vitor Pamplona
28bc46f120 Merge pull request #883 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 06:46:21 -04:00
Crowdin Bot
7a6de4ea49 New Crowdin translations by GitHub Action 2024-05-30 10:33:52 +00:00
Vitor Pamplona
bc108875e6 Merge pull request #882 from davotoula/update_tr
Update SE, CZ, DE, PT translations
2024-05-30 06:32:33 -04:00
David Kaspar
aa38b41c58 Update SE, CZ, DE, PT translations 2024-05-30 10:01:33 +02:00
Vitor Pamplona
a4c26b1fd8 Reducing the use of backgrounds to improve rendering speeds. 2024-05-29 18:53:26 -04:00
Vitor Pamplona
550cfe8bb4 Fixes relay list assembling 2024-05-29 15:58:47 -04:00
Vitor Pamplona
56664afa4e Show missing relays for DMs when the DMRelayList event is empty. 2024-05-29 15:22:19 -04:00
Vitor Pamplona
90c9deecd4 Removes the forced search relay selection and helps users setup their relay list for search. 2024-05-29 15:21:54 -04:00
Vitor Pamplona
add65b094d updates junit 2024-05-29 14:28:07 -04:00
Vitor Pamplona
c7a4161799 Fixing tests 2024-05-29 12:50:46 -04:00
Vitor Pamplona
f6a16e4394 Fixes date format's year 2024-05-29 12:28:22 -04:00
Vitor Pamplona
44a615020d Improves wording of the relay list 2024-05-29 12:28:10 -04:00
Vitor Pamplona
288aafc3a2 Adds support for search relay lists 2024-05-29 12:06:45 -04:00
Vitor Pamplona
aa97c7ee9b Adds NIP-65 relay settings 2024-05-29 10:39:08 -04:00
Vitor Pamplona
51a6eb4974 - Adds a new observer by kind and author
- Uses NIP-17 relays for DMs
- Changes default connection to account from NIP17 relays
- Only sends wraps to the appropriate relays.
2024-05-28 19:13:03 -04:00
Vitor Pamplona
f8dfc06a06 Saves DMs and All Relays on the relay list 2024-05-28 19:11:23 -04:00
Vitor Pamplona
2d4db9fea8 Refactoring on latestbykindandetag 2024-05-28 19:09:53 -04:00
Vitor Pamplona
34b743146e Making sure the relay list has different keys for different sections. 2024-05-28 18:48:41 -04:00
Vitor Pamplona
13eb8b1abc Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-28 11:49:20 -04:00
Vitor Pamplona
e98a77b37f Adds DM relays to the relay list. 2024-05-28 11:49:13 -04:00
Vitor Pamplona
6a17a8a871 Refactors GiftWrap caching to delete encrypted text and reload the wrap if necessary.
- Changes host to a host stub to reduce memory use
- Only download GiftWraps form 2 days past the last EOSE
2024-05-28 11:48:20 -04:00
Vitor Pamplona
3ce726bbb7 Merge pull request #881 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-28 08:27:01 -04:00
Crowdin Bot
593bf24f5b New Crowdin translations by GitHub Action 2024-05-28 12:22:25 +00:00
Vitor Pamplona
7a857711eb Merge pull request #880 from davotoula/update_tr
Update SE, CZ, DE, PT translations
2024-05-28 08:20:52 -04:00
David Kaspar
420e325d1f Update SE, CZ, DE, PT translations 2024-05-28 09:04:49 +02:00
Vitor Pamplona
610657a9b6 Merge pull request #877 from greenart7c3/main
add signString method
2024-05-27 08:15:34 -04:00
greenart7c3
ea891367e9 add signString method 2024-05-27 07:56:02 -03:00
Vitor Pamplona
0c22e66e8f Overrides pubkey to avoid impersonators on seals. 2024-05-24 19:27:14 -04:00
Vitor Pamplona
cd008d78a0 Improves the debug of the memory use per event kind 2024-05-24 19:26:39 -04:00
Vitor Pamplona
2c2e4a1694 Clears flows 2024-05-24 19:26:13 -04:00
Vitor Pamplona
d7790cd31e Moves the thread formatter and calculator out of Note 2024-05-24 18:11:57 -04:00
Vitor Pamplona
0bb571b52e Fixes position of the edit field after the relay list 2024-05-24 17:42:42 -04:00
Vitor Pamplona
c65a4b8fe7 Merge pull request #876 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-24 14:59:55 -04:00
Crowdin Bot
abb0c73c71 New Crowdin translations by GitHub Action 2024-05-24 18:56:33 +00:00
Vitor Pamplona
224bcf97ec Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-24 14:54:48 -04:00
Vitor Pamplona
04c449072a Adds support for NIP-06 seed word key derivation (bip32 and bip39) 2024-05-24 14:54:38 -04:00
Vitor Pamplona
b26aba92a1 Merge pull request #875 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-24 09:12:25 -04:00
Crowdin Bot
5ce9df9dce New Crowdin translations by GitHub Action 2024-05-24 12:20:21 +00:00
Vitor Pamplona
7426600dc6 deleting orangepill since it might not come back 2024-05-24 08:18:32 -04:00
Vitor Pamplona
9d4a88d26f Clear up some old unstable api tags 2024-05-23 18:18:46 -04:00
Vitor Pamplona
85aaf25561 Remove unnecessary logs 2024-05-23 17:11:18 -04:00
Vitor Pamplona
1cd5845960 Fixes the rendering of replies on wikipages. 2024-05-23 17:09:16 -04:00
Vitor Pamplona
e38f6be20c Refactoring the composables for the relay list screen 2024-05-23 16:40:24 -04:00
Vitor Pamplona
c78c00acd9 Adds method to create the NIP-65 list 2024-05-22 18:23:52 -04:00
Vitor Pamplona
11fa762028 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-22 17:46:45 -04:00
Vitor Pamplona
599fddb369 Adds a card to setup the DM relay list if the user doesn't have one. 2024-05-22 17:46:39 -04:00
Vitor Pamplona
76103ac057 Fixes the padding of the x button on the relay list screen 2024-05-22 17:45:55 -04:00
Vitor Pamplona
6538cbd9da Fixes padding of the chat list elements. 2024-05-22 17:45:18 -04:00
Vitor Pamplona
566ca0e390 Moves Relay URL formatter to Quartz 2024-05-22 17:44:18 -04:00
Vitor Pamplona
c5f7ea801d Update README.md
Adding the most recent feature sets
2024-05-22 14:54:21 -04:00
Vitor Pamplona
469e7abd99 Clears imeta tags that might duplicate for the same post. 2024-05-22 13:43:07 -04:00
Vitor Pamplona
e540954125 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-21 15:56:58 -04:00
Vitor Pamplona
f73052ed48 Checks cache before making a new request to DVM 2024-05-21 15:56:51 -04:00
Vitor Pamplona
a9437d9001 Refactors CreatedAt Comparator 2024-05-21 15:56:33 -04:00
Vitor Pamplona
02f6422bab Merge pull request #871 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-21 14:53:09 -04:00
Crowdin Bot
da2b59af49 New Crowdin translations by GitHub Action 2024-05-21 18:52:37 +00:00
Vitor Pamplona
7acdf56e68 Slightly improving the threading for badges. 2024-05-21 14:50:36 -04:00
Vitor Pamplona
24be6cd90d No need to launch, just use IO 2024-05-21 12:26:39 -04:00
Vitor Pamplona
afee0ddc53 Merge pull request #869 from believethehype/NIP90-ContentDiscovery
update paid dvms with processing status after payment
2024-05-21 08:41:38 -04:00
Believethehype
c85b8a1d83 update paid dvms with processing status after payment 2024-05-21 12:11:44 +02:00
Vitor Pamplona
cfeedfa4e2 Fixes the use of cache for video files. 2024-05-20 17:42:21 -04:00
Vitor Pamplona
dd112d28ae updating some libraries (others are not ready for the app) 2024-05-20 17:11:03 -04:00
Vitor Pamplona
fc27526113 Removes reposts from the Dot Notification in the home's bottom bar icon 2024-05-20 16:45:08 -04:00
Vitor Pamplona
666635811b Migrating to AGP 8.4.1 2024-05-20 16:42:11 -04:00
Vitor Pamplona
4e43938f96 Can zap DVMs directly on the regular zap button. 2024-05-20 16:22:27 -04:00
Vitor Pamplona
2a744205f0 Merge pull request #868 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-20 15:20:33 -04:00
Crowdin Bot
7e6ca34d2a New Crowdin translations by GitHub Action 2024-05-20 19:16:35 +00:00
Vitor Pamplona
24f7991116 Fixes a white space after a new line 2024-05-20 15:15:06 -04:00
Vitor Pamplona
beb901120e Fixes call from main thread 2024-05-20 13:25:53 -04:00
Vitor Pamplona
0936df9851 Fixes sat division and the state changing text. 2024-05-20 13:19:04 -04:00
Vitor Pamplona
9ceb8866ed Refactoring the DVM payment code. 2024-05-20 12:37:55 -04:00
Vitor Pamplona
c88b21b547 Merge pull request #867 from believethehype/NIP90-ContentDiscovery
Nip90 content discovery - Consider status and amount tags
2024-05-20 11:38:13 -04:00
Vitor Pamplona
5c366d5cfc Merge branch 'main' into NIP90-ContentDiscovery 2024-05-20 11:37:07 -04:00
Vitor Pamplona
ea70d44ac7 liniting 2024-05-20 11:24:03 -04:00
Believethehype
67f10920f6 Update NIP90ContentDiscoveryScreen.kt 2024-05-20 00:35:39 +02:00
Believethehype
654632a585 Update NIP90ContentDiscoveryScreen.kt 2024-05-20 00:32:26 +02:00
Believethehype
794b05106b add possibility to zap/pay invoices for dvms 2024-05-20 00:15:30 +02:00
Vitor Pamplona
5812e290c9 Merge pull request #866 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-17 18:32:35 -04:00
Crowdin Bot
c279c04858 New Crowdin translations by GitHub Action 2024-05-17 22:32:08 +00:00
Vitor Pamplona
01016525d3 Fixes missing parameter 2024-05-17 18:30:48 -04:00
Vitor Pamplona
72c6e93524 Migrates DVM route from pubkey to AppDefinition eventId and makes sure NFC reading will load the AppDefinition itself before rendering the rest of the screen. 2024-05-17 18:06:20 -04:00
Vitor Pamplona
d6988ad4e1 fixes redirection of routeFor when passing an App Definition Event. 2024-05-17 18:05:38 -04:00
Vitor Pamplona
4c1cd1c9ab adds DVM picture and name to the top bar and the status feed. 2024-05-17 17:18:31 -04:00
Vitor Pamplona
9fb8d4821e Refactoring the DVM codebase
Allows pull to refresh to request the job again.
2024-05-17 17:02:04 -04:00
Vitor Pamplona
8b052567c4 Adds a new observable system to local cache. 2024-05-17 17:01:57 -04:00
Vitor Pamplona
4a6ea550d6 Fixes the spacing in the Like button 2024-05-17 17:00:19 -04:00
Vitor Pamplona
5c88e7993f Fixes some of the missing classes due to R8 2024-05-16 15:14:51 -04:00
Vitor Pamplona
1bd1493bf4 Merge pull request #864 from greenart7c3/main
add paste from clipboard button to nwc
2024-05-16 13:47:19 -04:00
greenart7c3
404278a4e3 add paste from clipboard button to nwc 2024-05-16 14:13:40 -03:00
Vitor Pamplona
edca55b0b6 must not use crypto tools on the benchmark 2024-05-16 13:10:38 -04:00
Vitor Pamplona
738187d32b Making generateID public for testing 2024-05-16 13:05:15 -04:00
Vitor Pamplona
d9de0d2798 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-16 13:04:17 -04:00
Vitor Pamplona
b20515b1a0 Merge pull request #862 from believethehype/NIP90-ContentDiscovery
NIP90: cleanup, potential fix endless loop
2024-05-16 13:04:02 -04:00
Vitor Pamplona
e6d8291f07 Trying to unify NIP01 Serialization with SHA-256 procedures to reduce creation of several bytearray at every verification. 2024-05-16 13:03:30 -04:00
Vitor Pamplona
18d08bf6e0 Don't obfuscate data classes. 2024-05-16 13:01:15 -04:00
Believethehype
b2193f48d5 Update NIP90ContentDiscoveryScreen.kt 2024-05-16 16:48:19 +02:00
Vitor Pamplona
de391f03b1 Merge pull request #858 from VASHvic/auto_focus_search_screen
auto focus when entering the search screen
2024-05-16 09:29:21 -04:00
Believethehype
c494cf8ac1 Update NIP90ContentDiscoveryScreen.kt 2024-05-16 15:20:04 +02:00
Believethehype
193e9a5adb add more info on current dvm state 2024-05-16 15:19:19 +02:00
Believethehype
4fb9c93cf0 Merge branch 'NIP90-ContentDiscovery' of https://github.com/believethehype/amethyst into NIP90-ContentDiscovery 2024-05-16 15:16:33 +02:00
Believethehype
6bd98201f8 add DVM not replying message 2024-05-16 15:16:30 +02:00
believethehype
cbc6697631 Merge branch 'main' into NIP90-ContentDiscovery 2024-05-16 15:08:42 +02:00
Vitor Pamplona
d48634ac0e Merge pull request #861 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-16 09:03:48 -04:00
believethehype
5e2c8de15e Merge branch 'main' into NIP90-ContentDiscovery 2024-05-16 15:03:34 +02:00
Believethehype
926a721c53 cleanup, potential fix endless loop 2024-05-16 15:02:44 +02:00
Crowdin Bot
5bc6da3bfc New Crowdin translations by GitHub Action 2024-05-16 13:00:51 +00:00
Vitor Pamplona
eae066b003 Merge pull request #860 from believethehype/NIP90-ContentDiscovery
Fix order of events, use proper event content parsing
2024-05-16 08:58:54 -04:00
Believethehype
b74fa975ba Show DVM Status
Limitation: This will only show the first reply from the DVM, if mutliple status updates follow they will be ignored.
2024-05-16 14:24:13 +02:00
Believethehype
7f2b8519f3 improve parsing 2024-05-16 14:07:11 +02:00
Believethehype
51335e06f1 Fix order of Events in DVM feeds 2024-05-16 13:52:22 +02:00
VASH
0b6cd08c4a auto focus when entering the search screen 2024-05-15 21:59:21 +02:00
Vitor Pamplona
1bfe57da63 Fixes benchmark test for the new isDraft parameter 2024-05-15 10:28:01 -04:00
Vitor Pamplona
283d52ac2f Merge pull request #856 from believethehype/NIP90-ContentDiscovery
Nip90 Content Discovery
2024-05-15 10:17:10 -04:00
Vitor Pamplona
d1646761d2 Starts the process of adding a DM Relay list card to setup relays for GiftWraps 2024-05-15 10:00:54 -04:00
Believethehype
dc8209c90d Update NIP90ContentDiscoveryScreen.kt 2024-05-15 15:13:16 +02:00
Believethehype
420323bcb0 adjustments and cleanups 2024-05-15 15:11:36 +02:00
Believethehype
142aca40ce add NIP90 status events, update NIP90 feeds based on request id 2024-05-15 12:06:12 +02:00
Vitor Pamplona
4fa3d60638 Speeding up the url finder in the new post edit text. 2024-05-14 20:32:48 -04:00
Vitor Pamplona
a9eeb04014 Fixes alignment of the like icons after the like event. 2024-05-14 19:26:47 -04:00
Believethehype
f09b00ff01 adding 5301 events to be handled 2024-05-15 00:14:15 +02:00
Believethehype
ceaae398c2 Merge remote-tracking branch 'upstream/main' into NIP90-ContentDiscovery 2024-05-14 23:16:49 +02:00
Believethehype
39f87af072 remove notifications for NIP90 Content events 2024-05-14 23:09:43 +02:00
Vitor Pamplona
a34a3cbc83 Fixes GirtWrap test 2024-05-14 16:34:43 -04:00
Vitor Pamplona
f8afb4b783 Refactor the relay list for chat message event kind 2024-05-14 16:34:32 -04:00
Vitor Pamplona
fad4248539 Moves Zap the Devs card to a better position 2024-05-14 16:33:55 -04:00
Believethehype
13a53876d0 fix sorting 2024-05-14 22:21:03 +02:00
Believethehype
ea8affaebf general (unpolished) workflow works 2024-05-14 22:01:03 +02:00
Believethehype
df44e172ab Update NIP90ContentDiscoveryFilter.kt 2024-05-14 21:48:59 +02:00
Believethehype
8a50b3938d send request, get results (still needs parsing) 2024-05-14 21:41:36 +02:00
Believethehype
0245c907ff add nip90 events to factory 2024-05-14 18:51:38 +02:00
Vitor Pamplona
3bdc75b1be Improves the rendering of Channels and Communities when quoted 2024-05-14 12:40:53 -04:00
Vitor Pamplona
9d02361d01 Fixes wrong display of original and forked notes 2024-05-14 12:40:29 -04:00
Vitor Pamplona
6e6a13c5bf Moves video compressing quality to medium. 2024-05-14 12:12:48 -04:00
Vitor Pamplona
1f45a63081 Makes NIP-17 the default messaging mode
Migrates code from old NIP-24 to NIP-17
2024-05-14 11:34:36 -04:00
Believethehype
11a5f4a67e Merge remote-tracking branch 'upstream/main' into NIP90-ContentDiscovery 2024-05-14 17:06:00 +02:00
Believethehype
52e79580bf attempt to add own filters and event kinds for NIP90 content related events 2024-05-14 17:03:13 +02:00
Vitor Pamplona
561b19c447 Better rendering drafts in the thread 2024-05-14 10:16:41 -04:00
believethehype
9b21c3c964 Merge branch 'vitorpamplona:main' into NIP90-ContentDiscovery 2024-05-14 06:29:19 +02:00
Believethehype
baaa984d0d add nip90 content discovery request 2024-05-13 22:43:38 +02:00
Believethehype
2b7ef79d21 send dvm id to render list 2024-05-13 21:57:33 +02:00
Vitor Pamplona
22c96d2489 Use the default comparator instead of creating a new object every time 2024-05-13 15:40:24 -04:00
Vitor Pamplona
0c1187e4f5 Reducing the creation of new buttons when modifier objects change 2024-05-13 15:39:29 -04:00
Vitor Pamplona
7310ef175f DecimalFormats are not thread safe 2024-05-13 15:39:06 -04:00
Vitor Pamplona
53ba65cac2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-13 13:27:09 -04:00
Vitor Pamplona
58ed27dc75 Adds recommended amounts for the Zap the Devs
Block error messages from closing the Zap split payment screen.
2024-05-13 13:27:03 -04:00
Vitor Pamplona
065ba1c165 Better format zap amounts (don't show .0 if the previous numbers are large) 2024-05-13 13:25:50 -04:00
Believethehype
896d227fea Update DiscoverScreen.kt 2024-05-13 19:25:43 +02:00
Vitor Pamplona
6bac18c5df Fixes the offset position of the payment amounts on the Zap the Devs message 2024-05-13 13:24:53 -04:00
Vitor Pamplona
9ad62ef263 Reduces default zap amounts due to the change of bitcoin price 2024-05-13 13:22:58 -04:00
Believethehype
fe45e188bd hide subscription dvms for now 2024-05-13 18:17:36 +02:00
Believethehype
334b948900 app meta data, show content dvms 2024-05-13 17:27:34 +02:00
Vitor Pamplona
fb9ad2b457 Merge pull request #854 from jeremyd/main
allow relay selection dialog to pick any relays
2024-05-13 10:47:17 -04:00
Vitor Pamplona
921bb41596 Merge pull request #855 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-13 10:47:08 -04:00
Crowdin Bot
ab4d01583b New Crowdin translations by GitHub Action 2024-05-13 14:44:00 +00:00
Vitor Pamplona
314531e938 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-13 10:42:05 -04:00
Vitor Pamplona
991eed9bdf Merge pull request #845 from davotoula/843-URL-previews-mishandle-HTML-entities
843 url previews mishandle html entities
2024-05-13 10:40:33 -04:00
Believethehype
357981f266 add nip89 filter 2024-05-13 16:12:19 +02:00
Believethehype
119e9b7281 Structure for Content Discovery 2024-05-13 15:42:52 +02:00
Vitor Pamplona
8ca53e9707 Updates jackson 2024-05-10 19:47:40 -04:00
jeremyd
326e38f293 allow relay selection dialog to pick any relays (instead of just write relays) 2024-05-10 15:14:37 -07:00
Vitor Pamplona
6232e2682f Updates firebase 2024-05-09 19:10:19 -04:00
Vitor Pamplona
a2363221c6 Fixes Copy Text function of DraftEvents 2024-05-07 09:58:29 -04:00
Vitor Pamplona
1eef457b4e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-06 11:32:21 -04:00
Vitor Pamplona
fbd88bdeab Fixes top bar lists not updating when following communities and hashtags. 2024-05-06 11:32:14 -04:00
Vitor Pamplona
5d25bec1c9 Merge pull request #851 from VASHvic/hide_words_check
show toast error if unable to hide words
2024-05-04 15:59:36 -04:00
VASH
201a6d4462 show toast error if unable to hide words 2024-05-04 19:38:19 +02:00
Vitor Pamplona
1345ad3745 Merge pull request #847 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-01 13:17:55 -04:00
Crowdin Bot
b5f2a2b428 New Crowdin translations by GitHub Action 2024-05-01 17:16:56 +00:00
Vitor Pamplona
fe7b9b7930 Updates AGP 2024-05-01 13:09:29 -04:00
Vitor Pamplona
b24d3d863d ignores new idea file 2024-05-01 13:08:02 -04:00
Vitor Pamplona
3b7252616b Updates dependencies 2024-05-01 13:07:26 -04:00
Vitor Pamplona
c76ed3bb53 Merge pull request #846 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-24 17:06:42 -04:00
Crowdin Bot
cf775eebfb New Crowdin translations by GitHub Action 2024-04-24 21:05:58 +00:00
Vitor Pamplona
72018dc208 Base support for Relay lists for DMs. 2024-04-24 17:04:13 -04:00
Vitor Pamplona
fef635ab39 Fixes the use of Global-active relays in the Global Feed. 2024-04-24 13:54:13 -04:00
Vitor Pamplona
7a243af45c Updates compose, kotlin and okhttp 2024-04-24 09:53:17 -04:00
David Kaspar
94af0eb220 incremented matching group due to added matching group
added nbsp entity
2024-04-23 19:59:12 +02:00
David Kaspar
c0aea75c16 added test for html entity numbers to regular expression
added a few common html entities and entity numbers
2024-04-23 17:16:53 +02:00
Vitor Pamplona
175b79b291 Merge pull request #844 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-22 16:38:35 -04:00
Crowdin Bot
e5b8523bee New Crowdin translations by GitHub Action 2024-04-22 20:32:36 +00:00
Vitor Pamplona
1aecd9cf45 Minimizes costs of keeping track of the number of events received per subscription 2024-04-22 11:16:17 -04:00
Vitor Pamplona
6600a49564 Fixes the lack of refresh when adding hidden words in the Security filters 2024-04-18 19:34:15 -04:00
Vitor Pamplona
cdb65640ba Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-18 17:18:29 -04:00
Vitor Pamplona
0c9e76eeaf adds content type on CardFeedView 2024-04-18 17:17:17 -04:00
Vitor Pamplona
cfdbd0a9b6 Merge pull request #842 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-18 16:35:05 -04:00
Crowdin Bot
09b8178a7c New Crowdin translations by GitHub Action 2024-04-18 20:21:04 +00:00
Vitor Pamplona
5ea793eb51 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-18 16:19:21 -04:00
Vitor Pamplona
8cf04967c3 Makes sure only one pepare is run for each video view. 2024-04-18 14:46:01 -04:00
Vitor Pamplona
ef363457e8 Moves ClientController executor to a thread 2024-04-18 14:45:28 -04:00
Vitor Pamplona
e87394f3f7 Merge pull request #841 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-18 10:49:50 -04:00
Crowdin Bot
86ebfe8564 New Crowdin translations by GitHub Action 2024-04-18 14:24:22 +00:00
Vitor Pamplona
a2b3cfb991 Increases timeout to Tor connections 2024-04-18 10:22:19 -04:00
Vitor Pamplona
1b6aa621cd Merge pull request #840 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-17 17:55:02 -04:00
Crowdin Bot
b7f73c6eab New Crowdin translations by GitHub Action 2024-04-17 21:50:53 +00:00
Vitor Pamplona
e35fb88ff1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-17 17:49:00 -04:00
Vitor Pamplona
6ecb3c8e1f - Improves Zap efficiency for large zap splits
- Adds a queue of commands while the relay connects.
- Makes sure only one connection per URL is made when doing splits
- Removes unecessary Amber calls when decrypting private zaps
2024-04-17 17:48:53 -04:00
Vitor Pamplona
ff20f0a266 Deleting unecessary logs 2024-04-17 17:47:17 -04:00
Vitor Pamplona
a4cc6337f9 Don't use DM relays to find metadata 2024-04-17 17:43:40 -04:00
Vitor Pamplona
202b897029 Refactors AppScreen out of Activity. 2024-04-17 16:25:13 -04:00
Vitor Pamplona
79c174b92e Merge pull request #839 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-17 12:23:15 -04:00
Crowdin Bot
bdd3f19b2c New Crowdin translations by GitHub Action 2024-04-17 15:16:18 +00:00
Vitor Pamplona
afcc775d1b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-17 11:14:10 -04:00
Vitor Pamplona
8bbf308619 refactors the use of lud16 and lud06 2024-04-17 11:14:03 -04:00
Vitor Pamplona
df378937fe Adds a preview for UserNames with emojis 2024-04-17 11:12:51 -04:00
Vitor Pamplona
02ab7a3f3f Merge pull request #838 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-16 17:16:22 -04:00
Crowdin Bot
68ba9b3b91 New Crowdin translations by GitHub Action 2024-04-16 21:14:51 +00:00
Vitor Pamplona
f62833d1be Updates Firebase and Media3 dependencies 2024-04-16 17:11:52 -04:00
Vitor Pamplona
3be246c9cc Merge pull request #835 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-12 17:07:41 -04:00
Crowdin Bot
b28546b172 New Crowdin translations by GitHub Action 2024-04-12 21:05:53 +00:00
Vitor Pamplona
0fccfd7f80 clean up 2024-04-12 11:02:25 -04:00
Vitor Pamplona
3f35b57571 Improves Zap error messages to include the lnaddress of the error 2024-04-12 11:02:17 -04:00
Vitor Pamplona
32b9b6c37a Speeds up id calculations for Amber's Intent call.
Only assembles an id if necessary.
2024-04-12 11:01:32 -04:00
Vitor Pamplona
2342da114d Increases Amber signing cache. 2024-04-12 11:00:16 -04:00
Vitor Pamplona
ef0d77f8eb Removes hardcoded amber packages as default 2024-04-12 10:59:59 -04:00
Vitor Pamplona
5559b69bdb Displays Zap Split error messages in sequence instead of in multiple popups. 2024-04-12 10:58:32 -04:00
Vitor Pamplona
eda25b4cfe Callback uri is not necessary 2024-04-12 10:57:33 -04:00
Vitor Pamplona
9ce14e08fd Fixes a bug with signature-null in the sig of events. 2024-04-12 10:56:38 -04:00
Vitor Pamplona
b046fd91cb Only logs external signer calls on debug 2024-04-11 18:30:28 -04:00
Vitor Pamplona
8c9800664f v0.86.5 2024-04-11 18:20:48 -04:00
Vitor Pamplona
b90a57220d Enables Mutiny Wallet NWC 2024-04-11 18:18:54 -04:00
Vitor Pamplona
d16b0f58bb Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-11 18:04:53 -04:00
Vitor Pamplona
a538b66db3 Removes the use of DM relays to find events due to private inbox settings 2024-04-11 18:04:02 -04:00
Vitor Pamplona
f04631b0dd Adds vertical scrolling on the Zap page for collaborators. 2024-04-11 18:01:37 -04:00
Vitor Pamplona
6e31cff99c Avoids decrypting existing Nostr events just to add the relay into the relay list. 2024-04-11 18:01:22 -04:00
Vitor Pamplona
1553640c18 Calculates hash in the IO thread from Compose's scope. 2024-04-11 16:34:34 -04:00
Vitor Pamplona
68b8f9c82a Merge pull request #834 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-11 13:49:46 -04:00
Crowdin Bot
f6cce42028 New Crowdin translations by GitHub Action 2024-04-11 17:47:21 +00:00
955 changed files with 43828 additions and 17696 deletions

View File

@@ -18,11 +18,11 @@ jobs:
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
distribution: 'zulu'
java-version: 17
- name: Cache gradle
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
@@ -46,11 +46,11 @@ jobs:
uses: actions/upload-artifact@v2
with:
name: Debug APK
path: app/build/outputs/apk/debug/app-debug.apk
path: amethyst/build/outputs/apk/debug/app-debug.apk
- name: Upload Test Results
uses: actions/upload-artifact@v2
with:
name: Build Reports
path: app/build/reports
path: amethyst/build/reports

View File

@@ -15,11 +15,11 @@ jobs:
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
distribution: 'zulu'
java-version: 17
- name: Cache gradle
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
@@ -32,7 +32,7 @@ jobs:
- name: Sign AAB (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/playRelease
releaseDirectory: amethyst/build/outputs/bundle/playRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -44,7 +44,7 @@ jobs:
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/fdroidRelease
releaseDirectory: amethyst/build/outputs/bundle/fdroidRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -59,7 +59,7 @@ jobs:
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/play/release
releaseDirectory: amethyst/build/outputs/apk/play/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -71,7 +71,7 @@ jobs:
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/fdroid/release
releaseDirectory: amethyst/build/outputs/apk/fdroid/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -99,7 +99,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -110,7 +110,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -121,7 +121,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86_64-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -132,7 +132,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-arm64-v8a-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -143,7 +143,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-armeabi-v7a-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -155,7 +155,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-universal-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -166,7 +166,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -177,7 +177,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -188,7 +188,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -199,7 +199,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release-unsigned-signed.apk
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -213,7 +213,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/playRelease/app-play-release.aab
asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
@@ -225,6 +225,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip

4
.gitignore vendored
View File

@@ -7,9 +7,12 @@
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetDropDown.xml
/.idea/deploymentTargetSelector.xml
/.idea/appInsightsSettings.xml
/.idea/ktlint-plugin.xml
/.idea/ktfmt.xml
/.idea/studiobot.xml
/.idea/other.xml
.DS_Store
/build
/captures
@@ -39,6 +42,7 @@ out/
.gradle
.gradle/
build/
.kotlin/
# Local configuration file (sdk path, etc)
local.properties

View File

@@ -1,6 +1,30 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

2
.idea/kotlinc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.23" />
<option name="version" value="2.0.0" />
</component>
</project>

View File

@@ -43,7 +43,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] OpenTimestamps Attestations (NIP-03)
- [x] Private Messages (NIP-04)
- [x] DNS Address (NIP-05)
- [ ] Mnemonic seed phrase (NIP-06)
- [x] Mnemonic seed phrase (NIP-06)
- [ ] WebBrowser Signer (NIP-07, Not applicable)
- [x] Old-style mentions (NIP-08)
- [x] Event Deletion (NIP-09)
@@ -55,6 +55,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Events with a Subject (NIP-14)
- [ ] Marketplace (NIP-15)
- [x] Event Treatment (NIP-16)
- [x] Private Direct Messages (NIP-17)
- [x] Image/Video/Url/LnInvoice Previews
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
- [x] Bech Encoding support (NIP-19)
@@ -66,17 +67,20 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Delegated Event Signing (NIP-26, Will not implement)
- [x] Text Note References (NIP-27)
- [x] Public Chats (NIP-28)
- [ ] Relay-based Groups (NIP-29)
- [x] Custom Emoji (NIP-30)
- [x] Event kind summaries (NIP-31)
- [ ] Labeling (NIP-32)
- [x] Parameterized Replaceable Events (NIP-33)
- [x] Git Stuff (NIP-34/Draft)
- [x] Git Stuff (NIP-34)
- [ ] Torrents (NIP-35)
- [x] Sensitive Content (NIP-36)
- [x] Note Edits (NIP-37/Draft)
- [x] Edits (NIP-37/Draft)
- [x] User Status Event (NIP-38)
- [x] External Identities (NIP-39)
- [x] Expiration Support (NIP-40)
- [x] Relay Authentication (NIP-42)
- [x] Versioned Encrypted Payloads (NIP-44)
- [ ] Event Counts (NIP-45, Will not implement)
- [ ] Nostr Connect (NIP-46)
- [x] Wallet Connect API (NIP-47)
@@ -86,31 +90,32 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Lists (NIP-51)
- [ ] Calendar Events (NIP-52)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Wiki (NIP-54)
- [x] Inline Metadata (NIP-55 - Draft)
- [x] Reporting (NIP-56)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Private Zaps
- [x] Zap Splits (NIP-57)
- [x] Badges (NIP-58)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Zapraiser (NIP-TBD)
- [x] Badges (NIP-58)
- [ ] Relay List Metadata (NIP-65)
- [x] Polls (NIP-69)
- [x] Video Events (NIP-71)
- [x] Moderated Communities (NIP-72)
- [ ] Zap Goals (NIP-75)
- [ ] Arbitrary Custom App Data (NIP-78)
- [x] Highlights (NIP-84)
- [x] Notify Request (NIP-88/Draft)
- [x] Recommended Application Handlers (NIP-89)
- [ ] Data Vending Machine (NIP-90)
- [x] Data Vending Machine (NIP-90)
- [x] Inline Metadata (NIP-92)
- [x] Verifiable file URLs (NIP-94)
- [x] Binary Blobs (NIP-95)
- [x] HTTP File Storage Integration (NIP-96 Draft)
- [x] Binary Blobs (NIP-95/Draft)
- [x] HTTP File Storage Integration (NIP-96)
- [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99)
- [x] Private Messages and Small Groups (NIP-24/Draft)
- [x] Versioned Encrypted Payloads (NIP-44/Draft)
- [x] Audio Tracks (zapstr.live) (kind:31337)
- [x] Push Notifications (Google and Unified Push)
- [x] In-Device Automatic Translations
@@ -120,9 +125,15 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] De-googled F-Droid flavor
- [x] Multiple Accounts
- [x] Markdown Support
- [x] FHIR Payloads (kind:82)
- [ ] Decentralized Wiki (kind:30818)
- [ ] Embed events
- [x] Medical Data (NIP-xx/Draft)
- [x] Embed events (NIP-xx/Draft)
- [x] Draft Events (NIP-xx/Draft)
- [ ] Event Sets (NIP-xx/Draft)
- [ ] Topical Notes (NIP-xx/Draft)
- [ ] Relationship Status (NIP-xx/Draft)
- [ ] Signed Filters (NIP-xx/Draft)
- [ ] Key Migration (NIP-xx/Draft)
- [ ] Time-based Sync (NIP-xx/Draft)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] Workspaces
@@ -215,7 +226,7 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
- `KEY_PASSWORD` <- `<your password>`
- `KEY_STORE_PASSWORD` <- `<your key store password>`
- `SIGNING_KEY` <- the data from `<my-release-key.keystore>`
3. Change the `versionCode` and `versionName` on `app/build.gradle`
3. Change the `versionCode` and `versionName` on `amethyst/build.gradle`
4. Commit and push.
5. Tag the commit with `v{x.x.x}`
6. Let the [Create Release GitHub Action](https://github.com/vitorpamplona/amethyst/actions/workflows/create-release.yml) build a new `aab` file.

View File

@@ -1,20 +1,24 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}
android {
namespace 'com.vitorpamplona.amethyst'
compileSdk 34
compileSdk libs.versions.android.compileSdk.get().toInteger()
defaultConfig {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 367
versionName "0.86.4"
buildConfigField "String", "RELEASE_NOTES_ID", "\"a704a11334ed4fe6fc6ee6f8856f6f005da33644770616f1437f8b2b488b52b1\""
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 403
versionName "0.90.6"
buildConfigField "String", "RELEASE_NOTES_ID", "\"5d9a4fd0aba7ffefbf989152479237bd182578e58e59eb2fbfe94f6de8011803\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -132,19 +136,11 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
compose true
buildConfig true
}
composeOptions {
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion "1.5.11"
}
packagingOptions {
resources {
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
@@ -160,11 +156,23 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
implementation platform(libs.androidx.compose.bom)
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation project(path: ':ammolite')
implementation libs.androidx.core.ktx
implementation libs.androidx.activity.compose
@@ -245,6 +253,7 @@ dependencies {
// Language picker and Theme chooser
implementation libs.androidx.appcompat
implementation libs.androidx.window.core.android
// Local model for language identification
playImplementation libs.google.mlkit.language.id
@@ -276,6 +285,9 @@ dependencies {
// Image compression lib
implementation libs.zelory.video.compressor
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
testImplementation libs.junit
testImplementation libs.mockk
@@ -289,34 +301,3 @@ dependencies {
debugImplementation libs.androidx.ui.test.manifest
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
// NB: Android Studio can't find the imports; this does not affect the
// actual build since Gradle can find them just fine.
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
project.afterEvaluate {
tasks.each { task ->
if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) {
task.doLast {
outputs.files.each { file ->
if (file.name.endsWith(".profm")) {
println("Sorting ${file} ...")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}
}
}

View File

@@ -63,3 +63,5 @@
-keep class com.vitorpamplona.quartz.events.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
-keep class com.vitorpamplona.ammolite.service.** { *; }
-keep class com.vitorpamplona.ammolite.relays.** { *; }

View File

@@ -0,0 +1,102 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CashuBTest {
val cashuTokenA = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"
val cashuTokenB1 = "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA"
val cashuTokenB2 = "cashuBpGFkb01pbmliaXRzIHJ1bGVzIWFteEpodHRwOi8vbGJ1dGxoNWxmZ2dxNXI3eHBpd2hyYWpkbDdzeHB1cGdhZ2F6eGw2NXc0YzVjZzcyd3RvZmFzYWQub25pb246MzMzOGF0gaJhaUgAm7I9OpEuTmFwg6NhYRhAYWNYIQPfWR0mG80XbGnj6DO8q1NIyjHSGGIEkoWTA6H16HTpx2FzeEA3YThkY2Y5YjNlOGEyNDdjZTMzOWU3MzY5ZTliNGExOWYzMWVhY2I2OWQ4YjBjNjVkYWFlYjcyZDFhY2I5YWQzo2FhGCBhY1ghA55SwCFBc46dwnjbkb87Mzo30T2EE9Ws_nemuFneDegGYXN4QDlkODFjMWEyNjE2ODUzYWQ4MDQ5Y2JjZDFjN2MyNDdhZGQ4M2IzNzM4Mjg2MjBiYWMyZmQ3ZjNlNWE1OGFjZWKjYWEEYWNYIQM-yAQQTR2t6pIAmfmGM8Wxy7ajKVLOaUg7TrV8o-EdVWFzeEBmNmViMTI4ZmJlMDM3MTEzZTkzZjM3NjllYTYwMTk1NmY1N2NkZWNhNTYwOGY0NWUzMDhhZDU0ZmQ4YTQxNWVhYXVjc2F0"
@Test()
fun parseCashuA() {
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]
assertEquals(cashuTokenA, parsed.token)
assertEquals("https://8333.space:3338", parsed.mint)
assertEquals(10, parsed.totalAmount)
assertEquals(2, parsed.proofs[0].amount)
assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret)
assertEquals("009a1f293253e41e", parsed.proofs[0].id)
assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].C)
assertEquals(8, parsed.proofs[1].amount)
assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret)
assertEquals("009a1f293253e41e", parsed.proofs[1].id)
assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].C)
}
}
@Test()
fun parseCashuB() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB1, parsed[0].token)
assertEquals("http://localhost:3338", parsed[0].mint)
assertEquals(1, parsed[0].totalAmount)
assertEquals(1, parsed[0].proofs[0].amount)
assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret)
assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id)
assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].C)
assertEquals(3, parsed[1].totalAmount)
assertEquals(2, parsed[1].proofs[0].amount)
assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret)
assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id)
assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].C)
assertEquals(1, parsed[1].proofs[1].amount)
assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret)
assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id)
assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].C)
}
@Test()
fun parseCashuB2() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB2, parsed[0].token)
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)
assertEquals(100, parsed[0].totalAmount)
assertEquals(64, parsed[0].proofs[0].amount)
assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret)
assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id)
assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].C)
assertEquals(32, parsed[0].proofs[1].amount)
assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret)
assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id)
assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].C)
}
}

View File

@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst
import android.graphics.Bitmap
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Retriever
@@ -32,6 +34,9 @@ import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.KeyPair
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Ignore
@@ -60,7 +65,11 @@ class ImageUploadTesting {
val bytes = baos.toByteArray()
val inputStream = bytes.inputStream()
val account = Account(KeyPair())
val account =
Account(
AccountSettings(KeyPair()),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
val result =
Nip96Uploader(account)
@@ -72,12 +81,25 @@ class ImageUploadTesting {
sensitiveContent = null,
serverInfo,
onProgress = {},
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
val url = result.tags!!.first { it[0] == "url" }.get(1)
val size = result.tags!!.firstOrNull { it[0] == "size" }?.get(1)?.ifBlank { null }
val dim = result.tags!!.firstOrNull { it[0] == "dim" }?.get(1)?.ifBlank { null }
val hash = result.tags!!.firstOrNull { it[0] == "x" }?.get(1)?.ifBlank { null }
val size =
result.tags!!
.firstOrNull { it[0] == "size" }
?.get(1)
?.ifBlank { null }
val dim =
result.tags!!
.firstOrNull { it[0] == "dim" }
?.get(1)
?.ifBlank { null }
val hash =
result.tags!!
.firstOrNull { it[0] == "x" }
?.get(1)
?.ifBlank { null }
val contentType = result.tags!!.first { it[0] == "m" }.get(1)
val ox = result.tags!!.first { it[0] == "ox" }.get(1)
@@ -166,6 +188,12 @@ class ImageUploadTesting {
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
}
@Test(expected = RuntimeException::class)
fun testSprovoostNl() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sprovoost.nl", "https://img.sprovoost.nl/"))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrOnch() =

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.quartz.crypto.KeyPair
@@ -30,7 +31,9 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.Event
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Test
@@ -79,8 +82,7 @@ class ThreadAssemblerTest {
{"id":"512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","pubkey":"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194","created_at":1688194800,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","","reply"]],"content":"another one","sig":"b4bc4d8206a08de0a918043129b27c8863d17f60e4f58b4d2b535326625870d32640b04f247eb9eb5df1d62fe98c8e1b0341bdd119655553488300ec9d5b4036"},
{"id":"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1688234921,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","","reply"]],"content":"yet another one, testing 0.0.3","sig":"13bd41c4029c6a7ee41cb03d12e831e9e9b6e14d43a61c78a72657070b45385ba2ce98e8121049fbcbdb7b2dd777c36c7867ba70b5e6a4798a9b866910ae5b62"}
]
"""
.trimIndent()
""".trimIndent()
val header =
"""
@@ -111,8 +113,7 @@ class ThreadAssemblerTest {
["p","b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc"]
]
}
"""
.trimIndent()
""".trimIndent()
@Test
fun threadOrderTest() =
@@ -135,7 +136,7 @@ class ThreadAssemblerTest {
null,
)
val account = Account(KeyPair())
val account = Account(AccountSettings(KeyPair()), scope = CoroutineScope(Dispatchers.IO + SupervisorJob()))
withContext(Dispatchers.Main) {
val user = account.userProfile().live()
}

View File

@@ -0,0 +1,262 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.TransformedText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.UserMetadata
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class UrlUserTagTransformationTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
}
fun debugCursor(
original: String,
transformedText: TransformedText,
offset: Int,
): String {
val offsetTransformed = transformedText.offsetMapping.originalToTransformed(offset)
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
return "$originalWithCursor $transformedWithCursor"
}
fun debugCursorReverse(
original: String,
transformedText: TransformedText,
offsetTransformed: Int,
): String {
val offset = transformedText.offsetMapping.transformedToOriginal(offsetTransformed)
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
return "$originalWithCursor $transformedWithCursor"
}
@Test
fun testKeepTransformedIndexFullyInsideTransformedText() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(original),
Color.Red,
)
val expected = "@Vitor Pamplona"
assertEquals(expected, transformedText.text.text)
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 0))
assertEquals("@|npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 1))
assertEquals("@n|pub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 2))
assertEquals("@np|ub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 3))
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 4))
assertEquals("@npub|1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 5))
assertEquals("@npub1|gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 6))
assertEquals("@npub1g|cxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 7))
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 8))
assertEquals("@npub1gcx|zte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 9))
assertEquals("@npub1gcxz|te5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 10))
assertEquals("@npub1gcxzt|e5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 11))
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 12))
assertEquals("@npub1gcxzte5|zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 13))
assertEquals("@npub1gcxzte5z|lkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 14))
assertEquals("@npub1gcxzte5zl|kncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 15))
assertEquals("@npub1gcxzte5zlk|ncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 16))
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 17))
assertEquals("@npub1gcxzte5zlknc|x26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 18))
assertEquals("@npub1gcxzte5zlkncx|26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 19))
assertEquals("@npub1gcxzte5zlkncx2|6j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 20))
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 21))
assertEquals("@npub1gcxzte5zlkncx26j|68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 22))
assertEquals("@npub1gcxzte5zlkncx26j6|8ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 23))
assertEquals("@npub1gcxzte5zlkncx26j68|ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 24))
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 25))
assertEquals("@npub1gcxzte5zlkncx26j68ez|60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 26))
assertEquals("@npub1gcxzte5zlkncx26j68ez6|0fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 27))
assertEquals("@npub1gcxzte5zlkncx26j68ez60|fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 28))
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 29))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fz|kvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 30))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzk|vtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 31))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkv|tkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 32))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvt|km9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 33))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 34))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm|9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 35))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9|e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 36))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e|0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 37))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 38))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0v|rwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 39))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vr|wdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 40))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrw|dcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 41))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 42))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdc|vsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 43))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcv|sjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 44))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvs|jakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 45))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 46))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsja|kxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 47))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjak|xf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 48))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakx|f9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 49))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf|9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 50))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 51))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9m|u9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 52))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu|9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 53))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9|qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 54))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 55))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qe|wqlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 56))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qew|qlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 57))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewq|lfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 58))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 59))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlf|nj5z @Vitor Pamplon|a", debugCursor(original, transformedText, 60))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfn|j5z @Vitor Pamplon|a", debugCursor(original, transformedText, 61))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj|5z @Vitor Pamplon|a", debugCursor(original, transformedText, 62))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5|z @Vitor Pamplon|a", debugCursor(original, transformedText, 63))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursor(original, transformedText, 64))
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursorReverse(original, transformedText, 0))
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursorReverse(original, transformedText, 1))
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursorReverse(original, transformedText, 2))
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursorReverse(original, transformedText, 3))
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursorReverse(original, transformedText, 4))
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursorReverse(original, transformedText, 5))
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursorReverse(original, transformedText, 6))
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursorReverse(original, transformedText, 7))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursorReverse(original, transformedText, 8))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursorReverse(original, transformedText, 9))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursorReverse(original, transformedText, 10))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursorReverse(original, transformedText, 11))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pampl|ona", debugCursorReverse(original, transformedText, 12))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pamplo|na", debugCursorReverse(original, transformedText, 13))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplon|a", debugCursorReverse(original, transformedText, 14))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursorReverse(original, transformedText, 15))
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0))
assertEquals(15, transformedText.offsetMapping.originalToTransformed(64))
}
@Test
fun transformationText() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) // Before u
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(22, transformedText.offsetMapping.originalToTransformed(71))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8))
assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9))
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
}
@Test
fun transformationTextTwoKeys() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(
"New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
assertEquals(8, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
assertEquals(22, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) // Before z
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(67, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @
}
}

View File

@@ -42,9 +42,8 @@ object PushDistributorHandler : PushDistributorActions {
private val unifiedPush: UnifiedPush = UnifiedPush
private var endpointInternal = ""
val endpoint = endpointInternal
fun getSavedEndpoint() = endpoint
fun getSavedEndpoint() = endpointInternal
fun setEndpoint(newEndpoint: String) {
endpointInternal = newEndpoint
@@ -55,15 +54,11 @@ object PushDistributorHandler : PushDistributorActions {
endpointInternal = ""
}
override fun getSavedDistributor(): String {
return unifiedPush.getDistributor(appContext)
}
override fun getSavedDistributor(): String = unifiedPush.getSavedDistributor(appContext) ?: ""
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
override fun getInstalledDistributors(): List<String> {
return unifiedPush.getDistributors(appContext)
}
override fun getInstalledDistributors(): List<String> = unifiedPush.getDistributors(appContext)
fun formattedDistributorNames(): List<String> {
val distributorsArray = getInstalledDistributors().toTypedArray()
@@ -87,8 +82,7 @@ object PushDistributorHandler : PushDistributorActions {
it
}
as String
}
.toTypedArray()
}.toTypedArray()
return distributorsNameArray.toList()
}

View File

@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.util.Log
import android.util.LruCache
import androidx.core.content.ContextCompat
@@ -33,10 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
@@ -46,7 +42,7 @@ class PushMessageReceiver : MessagingReceiver() {
}
private val appContext = Amethyst.instance.applicationContext
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val scope = Amethyst.instance.applicationIOScope
private val eventCache = LruCache<String, String>(100)
private val pushHandler = PushDistributorHandler
@@ -55,11 +51,13 @@ class PushMessageReceiver : MessagingReceiver() {
message: ByteArray,
instance: String,
) {
val messageStr = String(message)
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
val messageStr = message.decodeToString()
Log.d(TAG, "New message $messageStr for Instance: $instance")
scope.launch {
try {
parseMessage(messageStr)?.let { receiveIfNew(it) }
parseMessage(messageStr)?.let {
receiveIfNew(it)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d(TAG, "Message could not be parsed: ${e.message}")
@@ -86,32 +84,25 @@ class PushMessageReceiver : MessagingReceiver() {
endpoint: String,
instance: String,
) {
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
val sanitizedEndpoint = endpoint.dropLast(5)
pushHandler.setEndpoint(sanitizedEndpoint)
scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
notificationManager().getOrCreateZapChannel(appContext)
notificationManager().getOrCreateDMChannel(appContext)
if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) {
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint")
pushHandler.setEndpoint(sanitizedEndpoint)
scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
notificationManager().getOrCreateZapChannel(appContext)
notificationManager().getOrCreateDMChannel(appContext)
}
} else {
Log.d(TAG, "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint")
}
}
override fun onReceive(
context: Context,
intent: Intent,
) {
val intentData = intent.dataString
val intentAction = intent.action.toString()
Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction")
super.onReceive(context, intent)
}
override fun onRegistrationFailed(
context: Context,
instance: String,
) {
Log.d(TAG, "Registration failed for Instance: $instance")
scope.cancel()
pushHandler.forceRemoveDistributor(context)
}
@@ -119,15 +110,14 @@ class PushMessageReceiver : MessagingReceiver() {
context: Context,
instance: String,
) {
val removedEndpoint = pushHandler.endpoint
val removedEndpoint = pushHandler.getSavedEndpoint()
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
Log.d(TAG, "App is unregistered. ")
pushHandler.forceRemoveDistributor(context)
pushHandler.removeEndpoint()
}
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(appContext, NotificationManager::class.java)
fun notificationManager(): NotificationManager =
ContextCompat.getSystemService(appContext, NotificationManager::class.java)
as NotificationManager
}
}

View File

@@ -29,11 +29,14 @@ object PushNotificationUtils {
private val pushHandler = PushDistributorHandler
suspend fun init(accounts: List<AccountInfo>) {
if (hasInit || pushHandler.savedDistributorExists()) {
if (hasInit) {
return
}
try {
if (pushHandler.savedDistributorExists()) {
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
}
} catch (e: Exception) {

View File

@@ -43,7 +43,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
@@ -56,10 +55,11 @@ import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckifItNeedsToRequestNotificationPermission
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -67,7 +67,7 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val notificationPermissionState =
CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
if (notificationPermissionState.status.isGranted) {
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
@@ -79,7 +79,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
if (readableListWithExplainer.size > 1) {
SpinnerSelectionDialog(
title = stringResource(id = R.string.select_push_server),
title = stringRes(id = R.string.select_push_server),
options = readableListWithExplainer,
onSelect = { index ->
if (list[index] == "None") {
@@ -101,9 +101,9 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
} else {
AlertDialog(
onDismissRequest = { distributorPresent = true },
title = { Text(stringResource(R.string.push_server_install_app)) },
title = { Text(stringRes(R.string.push_server_install_app)) },
text = {
val content = stringResource(R.string.push_server_install_app_description)
val content = stringRes(R.string.push_server_install_app_description)
val astNode =
remember {
@@ -128,7 +128,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
sharedPreferencesViewModel.dontShowPushNotificationSelector()
},
) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
Text(stringRes(R.string.quick_action_dont_show_again_button))
}
Button(
onClick = { distributorPresent = true },
@@ -142,7 +142,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_button_ok))
Text(stringRes(R.string.error_dialog_button_ok))
}
}
}
@@ -150,9 +150,6 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
)
}
}
} else {
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
}
}
}
@@ -168,20 +165,19 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
}
val readableListWithExplainer =
PushDistributorHandler.formattedDistributorNames()
PushDistributorHandler
.formattedDistributorNames()
.mapIndexed { index, name ->
TitleExplainer(
name,
stringResource(id = R.string.push_server_uses_app_explainer, list[index]),
stringRes(id = R.string.push_server_uses_app_explainer, list[index]),
)
}
.plus(
}.plus(
TitleExplainer(
stringResource(id = R.string.push_server_none),
stringResource(id = R.string.push_server_none_explainer),
stringRes(id = R.string.push_server_none),
stringRes(id = R.string.push_server_none_explainer),
),
)
.toImmutableList()
).toImmutableList()
onInner(
currentDistributor,

View File

@@ -36,6 +36,7 @@ fun TranslatableRichTextViewer(
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
id: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) = ExpandableRichTextViewer(
@@ -46,6 +47,7 @@ fun TranslatableRichTextViewer(
tags,
backgroundColor,
id,
callbackUri,
accountViewModel,
nav,
)

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,182 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package androidx.compose.material3.adaptive
import androidx.compose.runtime.Immutable
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toComposeRect
import androidx.window.layout.FoldingFeature
/**
* Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework
* folding info into the Material-opinionated posture info.
*/
fun calculatePosture(foldingFeatures: List<FoldingFeature>): Posture {
var isTableTop = false
val hingeList = mutableListOf<HingeInfo>()
@Suppress("ListIterator")
foldingFeatures.forEach {
if (it.orientation == FoldingFeature.Orientation.HORIZONTAL &&
it.state == FoldingFeature.State.HALF_OPENED
) {
isTableTop = true
}
hingeList.add(
HingeInfo(
bounds = it.bounds.toComposeRect(),
isFlat = it.state == FoldingFeature.State.FLAT,
isVertical = it.orientation == FoldingFeature.Orientation.VERTICAL,
isSeparating = it.isSeparating,
isOccluding = it.occlusionType == FoldingFeature.OcclusionType.FULL,
),
)
}
return Posture(isTableTop, hingeList)
}
/**
* Posture info that can help make layout adaptation decisions. For example when
* [Posture.separatingVerticalHingeBounds] is not empty, the layout may want to avoid putting any
* content over those hinge area. We suggest to use [calculatePosture] to retrieve instances of this
* class in applications, unless you have a strong need of customization that cannot be fulfilled by
* the default implementation.
*
* Note that the hinge bounds will be represent as [Rect] with window coordinates, instead of layout
* coordinate.
*
* @constructor create an instance of [Posture]
* @property isTabletop `true` if the current window is considered as in the table top mode, i.e.
* there is one half-opened horizontal hinge in the middle of the current window. When
* this is `true` it usually means it's hard for users to interact with the window area
* around the hinge and developers may consider separating the layout along the hinge and
* show software keyboard or other controls in the bottom half of the window.
* @property hingeList a list of all hinges that are relevant to the posture.
*/
@Immutable
class Posture(
val isTabletop: Boolean = false,
val hingeList: List<HingeInfo> = emptyList(),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Posture) return false
if (isTabletop != other.isTabletop) return false
if (hingeList != other.hingeList) return false
return true
}
override fun hashCode(): Int {
var result = isTabletop.hashCode()
result = 31 * result + hingeList.hashCode()
return result
}
override fun toString(): String {
@Suppress("ListIterator")
return "Posture(isTabletop=$isTabletop, " +
"hinges=[${hingeList.joinToString(", ")}])"
}
}
/**
* Returns the list of vertical hinge bounds that are separating.
*/
val Posture.separatingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isSeparating }
/**
* Returns the list of vertical hinge bounds that are occluding.
*/
val Posture.occludingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isOccluding }
/**
* Returns the list of all vertical hinge bounds.
*/
val Posture.allVerticalHingeBounds get() = hingeList.getBounds { isVertical }
/**
* Returns the list of horizontal hinge bounds that are separating.
*/
val Posture.separatingHorizontalHingeBounds
get() = hingeList.getBounds { !isVertical && isSeparating }
/**
* Returns the list of horizontal hinge bounds that are occluding.
*/
val Posture.occludingHorizontalHingeBounds
get() = hingeList.getBounds { !isVertical && isOccluding }
/**
* Returns the list of all horizontal hinge bounds.
*/
val Posture.allHorizontalHingeBounds
get() = hingeList.getBounds { !isVertical }
/**
* A class that contains the info of a hinge relevant to a [Posture].
*
* @param bounds the bounds of the hinge in the relevant viewport.
* @param isFlat `true` if the hinge is fully open and the relevant window space presented to the
* user is flat.
* @param isVertical `true` if the hinge is a vertical one, i.e., it separates the viewport into
* left and right; `false` if the hinge is horizontal, i.e., it separates the viewport
* into top and bottom.
* @param isSeparating `true` if the hinge creates two logical display areas.
* @param isOccluding `true` if the hinge conceals part of the display.
*/
@Immutable
class HingeInfo(
val bounds: Rect,
val isFlat: Boolean,
val isVertical: Boolean,
val isSeparating: Boolean,
val isOccluding: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is HingeInfo) return false
if (bounds != other.bounds) return false
if (isFlat != other.isFlat) return false
if (isVertical != other.isVertical) return false
if (isSeparating != other.isSeparating) return false
if (isOccluding != other.isOccluding) return false
return true
}
override fun hashCode(): Int {
var result = bounds.hashCode()
result = 31 * result + isFlat.hashCode()
result = 31 * result + isVertical.hashCode()
result = 31 * result + isSeparating.hashCode()
result = 31 * result + isOccluding.hashCode()
return result
}
override fun toString(): String =
"HingeInfo(bounds=$bounds, " +
"isFlat=$isFlat, " +
"isVertical=$isVertical, " +
"isSeparating=$isSeparating, " +
"isOccluding=$isOccluding)"
}
private inline fun List<HingeInfo>.getBounds(predicate: HingeInfo.() -> Boolean): List<Rect> =
@Suppress("ListIterator")
mapNotNull { if (it.predicate()) it.bounds else null }

View File

@@ -0,0 +1,122 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package androidx.compose.material3.adaptive
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
import androidx.window.core.layout.WindowSizeClass
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowMetricsCalculator
import kotlinx.coroutines.flow.map
@Composable
fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo {
val windowSize =
with(LocalDensity.current) {
currentWindowSize().toSize().toDpSize()
}
return WindowAdaptiveInfo(
WindowSizeClass.compute(windowSize.width.value, windowSize.height.value),
calculatePosture(collectFoldingFeaturesAsState().value),
)
}
/**
* Returns and automatically update the current window size from [WindowMetricsCalculator].
*
* @return an [IntSize] that represents the current window size.
*/
@Composable
fun currentWindowSize(): IntSize {
// Observe view configuration changes and recalculate the size class on each change. We can't
// use Activity#onConfigurationChanged as this will sometimes fail to be called on different
// API levels, hence why this function needs to be @Composable so we can observe the
// ComposeView's configuration changes.
LocalConfiguration.current
val windowBounds =
WindowMetricsCalculator
.getOrCreate()
.computeCurrentWindowMetrics(LocalContext.current)
.bounds
return IntSize(windowBounds.width(), windowBounds.height())
}
/**
* Collects the current window folding features from [WindowInfoTracker] in to a [State].
*
* @return a [State] of a [FoldingFeature] list.
*/
@Composable
fun collectFoldingFeaturesAsState(): State<List<FoldingFeature>> {
val context = LocalContext.current
return remember(context) {
if (context is Activity) {
// TODO(b/284347941) remove the instance check after the test bug is fixed.
WindowInfoTracker
.getOrCreate(context)
.windowLayoutInfo(context)
} else {
WindowInfoTracker
.getOrCreate(context)
.windowLayoutInfo(context)
}.map { it.displayFeatures.filterIsInstance<FoldingFeature>() }
}.collectAsState(emptyList())
}
/**
* This class collects window info that affects adaptation decisions. An adaptive layout is supposed
* to use the info from this class to decide how the layout is supposed to be adapted.
*
* @constructor create an instance of [WindowAdaptiveInfo]
* @param windowSizeClass [WindowSizeClass] of the current window.
* @param windowPosture [Posture] of the current window.
*/
@Immutable
class WindowAdaptiveInfo(
val windowSizeClass: WindowSizeClass,
val windowPosture: Posture,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is WindowAdaptiveInfo) return false
if (windowSizeClass != other.windowSizeClass) return false
if (windowPosture != other.windowPosture) return false
return true
}
override fun hashCode(): Int {
var result = windowSizeClass.hashCode()
result = 31 * result + windowPosture.hashCode()
return result
}
override fun toString(): String = "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass, windowPosture=$windowPosture)"
}

View File

@@ -118,7 +118,5 @@ private class PullRefreshNestedScrollConnection(
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y))
}
override suspend fun onPreFling(available: Velocity): Velocity = Velocity(0f, onRelease(available.y))
}

View File

@@ -63,8 +63,7 @@ fun Modifier.pullRefreshIndicatorTransform(
) {
this@drawWithContent.drawContent()
}
}
.graphicsLayer {
}.graphicsLayer {
translationY = state.position - size.height
if (scale && !state.refreshing) {

View File

@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.os.Looper
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
@@ -29,23 +31,29 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.service.playback.VideoCache
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.ots.OpenTimestamps
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
import kotlin.time.measureTimedValue
class Amethyst : Application() {
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Service Manager is only active when the activity is active.
val serviceManager = ServiceManager(applicationIOScope)
override fun onTerminate() {
super.onTerminate()
applicationIOScope.cancel()
@@ -53,31 +61,53 @@ class Amethyst : Application() {
val videoCache: VideoCache by lazy {
val newCache = VideoCache()
newCache.initFileCache(this)
runBlocking {
newCache.initFileCache(this@Amethyst)
}
newCache
}
val coilCache: DiskCache by lazy {
DiskCache.Builder()
DiskCache
.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
.build()
}
val coilMemCache: MemoryCache by lazy {
MemoryCache
.Builder(this)
.build()
}
fun contentResolverFn(): ContentResolver = contentResolver
override fun onCreate() {
super.onCreate()
instance = this
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
if (BuildConfig.DEBUG) {
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder().detectAll().penaltyLog().build(),
ThreadPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
VmPolicy.Builder().detectAll().penaltyLog().build(),
VmPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
Looper.getMainLooper().setMessageLogging(LogMonitor())
ChoreographerHelper.start()
}
GlobalScope.launch(Dispatchers.IO) {
@@ -90,12 +120,25 @@ class Amethyst : Application() {
}
}
fun imageLoaderBuilder(): ImageLoader.Builder {
return ImageLoader.Builder(applicationContext).diskCache { coilCache }
}
fun imageLoaderBuilder(): ImageLoader.Builder =
ImageLoader
.Builder(this)
.diskCache { coilCache }
.memoryCache { coilMemCache }
.crossfade(true)
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences {
return EncryptedStorage.preferences(instance, npub)
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
/**
* Release memory when the UI becomes hidden or when system resources become low.
*
* @param level the memory-related event that was raised.
*/
@OptIn(DelicateCoroutinesApi::class)
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() }
}
companion object {

View File

@@ -29,16 +29,15 @@ class EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
// returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
}
fun prefsFileName(npub: String? = null): String = if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
fun preferences(
applicationContext: Context,
npub: String? = null,
): EncryptedSharedPreferences {
val masterKey: MasterKey =
MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
MasterKey
.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()

View File

@@ -0,0 +1,624 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
// To use plaintext SharedPreferences for debugging, set this to true
// It will only apply in Debug builds
private const val DEBUG_PLAINTEXT_PREFERENCES = false
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
@Immutable
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val loggedInWithExternalSigner: Boolean,
)
private object PrefKeys {
const val CURRENT_ACCOUNT = "currently_logged_in_account"
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val REACTION_CHOICES = "reactionChoices"
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 DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_USER_METADATA = "latestUserMetadata"
const val LATEST_CONTACT_LIST = "latestContactList"
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
const val LATEST_MUTE_LIST = "latestMuteList"
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
const val WARN_ABOUT_REPORTS = "warn_about_reports"
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
const val SIGNER_PACKAGE_NAME = "signer_package_name"
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val PENDING_ATTESTATIONS = "pending_attestations"
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
}
object LocalPreferences {
private const val COMMA = ","
private var currentAccount: String? = null
private var savedAccounts: List<AccountInfo>? = null
private var cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
suspend fun currentAccount(): String? {
if (currentAccount == null) {
currentAccount =
withContext(Dispatchers.IO) {
encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
}
return currentAccount
}
private suspend fun updateCurrentAccount(npub: String?) {
if (npub == null) {
currentAccount = null
withContext(Dispatchers.IO) {
encryptedPreferences().edit().clear().apply()
}
} else if (currentAccount != npub) {
currentAccount = npub
withContext(Dispatchers.IO) {
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
}
}
}
private suspend fun savedAccounts(): List<AccountInfo> {
if (savedAccounts == null) {
withContext(Dispatchers.IO) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
Event.mapper.readValue<List<AccountInfo>>(it)
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
savedAccounts = newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
val migrated =
oldAccounts.map { npub ->
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "")
.isNotBlank(),
)
}
savedAccounts = migrated
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply()
}
}
}
}
return savedAccounts!!
}
fun cachedAccounts() = savedAccounts
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) =
withContext(Dispatchers.IO) {
if (savedAccounts != accounts) {
savedAccounts = accounts
encryptedPreferences()
.edit()
.apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts)) }
.apply()
}
}
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
private suspend fun addAccount(accInfo: AccountInfo) {
val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo)
updateSavedAccounts(accounts)
}
private suspend fun setCurrentAccount(accountSettings: AccountSettings) {
val npub = accountSettings.keyPair.pubKey.toNpub()
val accInfo =
AccountInfo(
npub,
accountSettings.isWriteable(),
accountSettings.externalSignerPackageName != null,
)
updateCurrentAccount(npub)
addAccount(accInfo)
}
suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo.npub)
/** Removes the account from the app level shared preferences */
private suspend fun removeAccount(accountInfo: AccountInfo) {
updateSavedAccounts(savedAccounts().filter { it.npub != accountInfo.npub })
}
/** Deletes the npub-specific shared preference file */
private suspend fun deleteUserPreferenceFile(npub: String) {
withContext(Dispatchers.IO) {
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
File(prefsDir, it).delete()
}
}
}
}
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
checkNotInMainThread()
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile =
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
return Amethyst.instance.encryptedStorage(npub)
}
}
/**
* Clears the preferences for a given npub, deletes the preferences xml file, and switches the
* user to the first account in the list if it exists
*
* We need to use `commit()` to write changes to disk and release the file lock so that it can be
* deleted. If we use `apply()` there is a race condition and the file will probably not be
* deleted
*/
@SuppressLint("ApplySharedPref")
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout")
withContext(Dispatchers.IO) {
encryptedPreferences(accountInfo.npub).edit().clear().commit()
removeAccount(accountInfo)
deleteUserPreferenceFile(accountInfo.npub)
if (savedAccounts().isEmpty()) {
updateCurrentAccount(null)
} else if (currentAccount() == accountInfo.npub) {
updateCurrentAccount(savedAccounts().elementAt(0).npub)
}
}
}
suspend fun updatePrefsForLogin(accountSettings: AccountSettings) {
setCurrentAccount(accountSettings)
saveToEncryptedStorage(accountSettings)
}
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
suspend fun saveToEncryptedStorage(settings: AccountSettings) {
Log.d("LocalPreferences", "Saving to encrypted storage")
withContext(Dispatchers.IO) {
val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub())
prefs
.edit()
.apply {
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null)
if (settings.externalSignerPackageName != null) {
remove(PrefKeys.NOSTR_PRIVKEY)
putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName)
} else {
remove(PrefKeys.SIGNER_PACKAGE_NAME)
settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
}
settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(settings.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom)
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
putString(
PrefKeys.LANGUAGE_PREFS,
Event.mapper.writeValueAsString(settings.languagePreferences),
)
putString(PrefKeys.TRANSLATE_TO, settings.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value))
putString(
PrefKeys.REACTION_CHOICES,
Event.mapper.writeValueAsString(settings.reactionChoices.value),
)
putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name)
putString(
PrefKeys.DEFAULT_FILE_SERVER,
Event.mapper.writeValueAsString(settings.defaultFileServer),
)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
putString(
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
settings.defaultNotificationFollowList.value,
)
putString(
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
settings.defaultDiscoveryFollowList.value,
)
putString(
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
Event.mapper.writeValueAsString(settings.zapPaymentRequest),
)
if (settings.backupContactList != null) {
putString(
PrefKeys.LATEST_CONTACT_LIST,
Event.mapper.writeValueAsString(settings.backupContactList),
)
} else {
remove(PrefKeys.LATEST_CONTACT_LIST)
}
if (settings.backupUserMetadata != null) {
putString(
PrefKeys.LATEST_USER_METADATA,
Event.mapper.writeValueAsString(settings.backupUserMetadata),
)
} else {
remove(PrefKeys.LATEST_USER_METADATA)
}
if (settings.backupDMRelayList != null) {
putString(
PrefKeys.LATEST_DM_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupDMRelayList),
)
} else {
remove(PrefKeys.LATEST_DM_RELAY_LIST)
}
if (settings.backupNIP65RelayList != null) {
putString(
PrefKeys.LATEST_NIP65_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupNIP65RelayList),
)
} else {
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
}
if (settings.backupSearchRelayList != null) {
putString(
PrefKeys.LATEST_SEARCH_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupSearchRelayList),
)
} else {
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
}
if (settings.backupMuteList != null) {
putString(
PrefKeys.LATEST_MUTE_LIST,
Event.mapper.writeValueAsString(settings.backupMuteList),
)
} else {
remove(PrefKeys.LATEST_MUTE_LIST)
}
if (settings.backupPrivateHomeRelayList != null) {
putString(
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
)
} else {
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, settings.proxy != null)
putInt(PrefKeys.PROXY_PORT, settings.proxyPort)
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers)
val regularMap =
settings.lastReadPerRoute.value.mapValues {
it.value.value
}
putString(
PrefKeys.LAST_READ_PER_ROUTE,
Event.mapper.writeValueAsString(regularMap),
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
if (settings.showSensitiveContent.value == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!)
}
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(settings.pendingAttestations.value),
)
}.apply()
}
}
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
suspend fun saveSharedSettings(
sharedSettings: Settings,
prefs: SharedPreferences = encryptedPreferences(),
) {
Log.d("LocalPreferences", "Saving to shared settings")
with(prefs.edit()) {
putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings))
apply()
}
}
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
Log.d("LocalPreferences", "Load shared settings")
with(prefs) {
return try {
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue<Settings>(it) }
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}",
e,
)
e.printStackTrace()
null
}
}
}
val mutex = Mutex()
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): AccountSettings? {
// if already loaded, return right away
if (cachedAccounts.containsKey(npub)) {
return cachedAccounts[npub]
}
return withContext(Dispatchers.IO) {
mutex.withLock {
if (cachedAccounts.containsKey(npub)) {
return@withContext cachedAccounts.get(npub)
}
val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub)
cachedAccounts.put(npub, accountSettings)
return@withContext accountSettings
}
}
}
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
Log.d("LocalPreferences", "Load account from file $npub")
return withContext(Dispatchers.IO) {
checkNotInMainThread()
return@withContext with(encryptedPreferences(npub)) {
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null
val externalSignerPackageName =
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, 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 defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultZapType =
getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val defaultFileServer = parseOrNull<Nip96MediaServers.ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0]
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val lastReadPerRoute =
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
MutableStateFlow(it.value)
} ?: mapOf()
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
return@with AccountSettings(
keyPair = keyPair,
externalSignerPackageName = externalSignerPackageName,
localRelays = localRelays,
localRelayServers = localRelayServers,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
zapAmountChoices = MutableStateFlow(zapAmountChoices.toImmutableList()),
reactionChoices = MutableStateFlow(reactionChoices.toImmutableList()),
defaultZapType = MutableStateFlow(defaultZapType),
defaultFileServer = defaultFileServer,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
backupUserMetadata = latestUserMetadata,
backupContactList = latestContactList,
backupNIP65RelayList = latestNip65RelayList,
backupDMRelayList = latestDmRelayList,
backupSearchRelayList = latestSearchRelayList,
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
backupMuteList = latestMuteList,
proxy = proxy,
proxyPort = proxyPort,
showSensitiveContent = MutableStateFlow(showSensitiveContent),
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
pendingAttestations = MutableStateFlow(pendingAttestations),
)
}
}
}
private inline fun <reified T> SharedPreferences.parseOrNull(key: String): T? {
val value = getString(key, null)
if (value.isNullOrEmpty() || value == "null") {
return null
}
return try {
if (T::class.java.isInstance(Event::class.java)) {
Event.fromJson(value) as T?
} else {
Event.mapper.readValue<T?>(value)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
e.printStackTrace()
null
}
}
private inline fun <reified T> SharedPreferences.parseEventOrNull(key: String): T? {
val value = getString(key, null)
if (value.isNullOrEmpty() || value == "null") {
return null
}
return try {
Event.fromJson(value) as T?
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
e.printStackTrace()
null
}
}
}

View File

@@ -0,0 +1,198 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.util.Log
import android.util.Printer
import android.view.Choreographer
import java.text.SimpleDateFormat
import java.util.concurrent.atomic.AtomicBoolean
class LogMonitor : Printer {
private val mStackSampler: StackSampler
private var mPrintingStarted = false
private var mStartTimestamp: Long = 0
// threshold
private val mBlockThresholdMillis: Long = 16
// Sampling frequency
private val mSampleInterval: Long = 1000
private val mLogHandler: Handler
init {
mStackSampler = StackSampler(mSampleInterval)
val handlerThread = HandlerThread("block-canary-io")
handlerThread.start()
mLogHandler = Handler(handlerThread.getLooper())
}
override fun println(x: String) {
// From if to Else, execute DispatchMessage. If the execution takes more than the threshold, the output stuck information
if (!mPrintingStarted) {
// Record start time
mStartTimestamp = System.currentTimeMillis()
mPrintingStarted = true
mStackSampler.startDump()
} else {
val endTime = System.currentTimeMillis()
if (x.indexOf("com.vitorpamplona.amethyst") > 0) {
Log.d("block-canary", "Looper ${endTime - mStartTimestamp}ms for $x")
}
mPrintingStarted = false
//
if (isBlock(endTime)) {
notifyBlockEvent(endTime)
}
mStackSampler.stopDump()
}
}
private fun notifyBlockEvent(endTime: Long) {
mLogHandler.post {
// Obtain the stack of the main thread stack
val stacks: List<String> = mStackSampler.getStacks(mStartTimestamp, endTime)
for (stack in stacks) {
Log.e("block-canary", stack)
}
}
}
private fun isBlock(endTime: Long): Boolean = endTime - mStartTimestamp > mBlockThresholdMillis
}
class StackSampler(
private val mSampleInterval: Long,
) {
private val mHandler: Handler
private val mStackMap: MutableMap<Long, String> = LinkedHashMap()
private val mMaxCount = 100
// Whether to sample
var mShouldSample: AtomicBoolean = AtomicBoolean(false)
/**
* Start sampling and execute stack
*/
fun startDump() {
// Avoid repeating start
if (mShouldSample.get()) {
return
}
mShouldSample.set(true)
mHandler.removeCallbacks(mRunnable)
mHandler.postDelayed(mRunnable, mSampleInterval)
}
fun stopDump() {
if (!mShouldSample.get()) {
return
}
mShouldSample.set(false)
mHandler.removeCallbacks(mRunnable)
}
fun getStacks(
startTime: Long,
endTime: Long,
): List<String> {
val result = mutableListOf<String>()
synchronized(mStackMap) {
for (entryTime in mStackMap.keys) {
if (startTime < entryTime && entryTime < endTime) {
result.add(
TIME_FORMATTER.format(entryTime) +
SEPARATOR +
SEPARATOR +
mStackMap[entryTime],
)
}
}
}
return result
}
init {
val handlerThread = HandlerThread("block-canary-sampler")
handlerThread.start()
mHandler = Handler(handlerThread.looper)
}
private val mRunnable: Runnable =
object : Runnable {
override fun run() {
val sb = StringBuilder()
val stackTrace = Looper.getMainLooper().thread.stackTrace
for (s in stackTrace) {
sb.append(s.toString()).append("\n")
}
synchronized(mStackMap) {
// Save up to 100 stack information
if (mStackMap.size == mMaxCount) {
mStackMap.remove(mStackMap.keys.iterator().next())
}
mStackMap.put(System.currentTimeMillis(), sb.toString())
}
if (mShouldSample.get()) {
mHandler.postDelayed(this, mSampleInterval)
}
}
}
companion object {
const val SEPARATOR: String = "\r\n"
val TIME_FORMATTER: SimpleDateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
}
}
object ChoreographerHelper {
var lastFrameTimeNanos: Long = 0
fun start() {
Choreographer.getInstance().postFrameCallback(
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// Last callback time
if (lastFrameTimeNanos == 0L) {
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
return
}
val diff = (frameTimeNanos - lastFrameTimeNanos) / 1000000
// only report after 30ms because videos play at 30fps
if (diff > 35) {
// Follow the frame number
val droppedCount = (diff / 16.6).toInt()
Log.w("block-canary", "Dropped $droppedCount frames. Skipped $diff ms")
}
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
}
},
)
}
}

View File

@@ -28,9 +28,10 @@ import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.size.Precision
import coil.util.DebugLogger
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.Base64Fetcher
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -47,7 +48,8 @@ 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.ammolite.relays.Client
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
@@ -56,17 +58,19 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@Stable
class ServiceManager {
class ServiceManager(
val scope: CoroutineScope,
) {
private var isStarted: Boolean =
false // to not open amber in a loop trying to use auth relays and registering for notifications
private var account: Account? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var collectorJob: Job? = null
private fun start(account: Account) {
@@ -84,8 +88,9 @@ class ServiceManager {
val myAccount = account
// Resets Proxy Use
HttpClientManager.setDefaultProxy(account?.proxy)
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
HttpClientManager.setDefaultProxy(account?.settings?.proxy)
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
Amethyst.instance
.imageLoaderBuilder()
@@ -96,15 +101,19 @@ class ServiceManager {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClientManager.getHttpClient() }
add(Base64Fetcher.Factory)
}.apply {
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
this.logger(DebugLogger())
}
}.okHttpClient { HttpClientManager.getHttpClient() }
.precision(Precision.INEXACT)
.respectCacheHeaders(false)
.build()
}
if (myAccount != null) {
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
val relaySet = myAccount.connectToRelays.value
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
Client.reconnect(relaySet)
@@ -112,30 +121,33 @@ class ServiceManager {
collectorJob = null
collectorJob =
scope.launch {
myAccount.userProfile().flow().relays.stateFlow.collect {
myAccount.connectToRelaysFlow.collectLatest {
delay(500)
if (isStarted) {
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Client.reconnect(newRelaySet, onlyIfChanged = true)
Client.reconnect(it, onlyIfChanged = true)
}
}
}
// start services
NostrAccountDataSource.account = myAccount
NostrAccountDataSource.otherAccounts =
LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
NostrAccountDataSource.otherAccounts =
runBlocking {
LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
}
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
@@ -186,7 +198,7 @@ class ServiceManager {
LocalCache.cleanObservers()
}
fun trimMemory() {
suspend fun trimMemory() {
LocalCache.cleanObservers()
val accounts =

View File

@@ -0,0 +1,524 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.ammolite.relays.Constants
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import java.net.Proxy
import java.util.Locale
val DefaultChannels =
setOf(
// Anigma's Nostr
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
// Amethyst's Group
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
)
val DefaultReactions =
persistentListOf(
"\uD83D\uDE80",
"\uD83E\uDEC2",
"\uD83D\uDC40",
"\uD83D\uDE02",
"\uD83C\uDF89",
"\uD83E\uDD14",
"\uD83D\uDE31",
)
val DefaultNIP65List =
listOf(
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nos.lol/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
)
val DefaultDMRelayList =
listOf(
RelayUrlFormatter.normalize("wss://auth.nostr1.com/"),
RelayUrlFormatter.normalize("wss://nostr.mom/"),
RelayUrlFormatter.normalize("wss://nos.lol/"),
)
val DefaultSearchRelayList =
listOf(
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
RelayUrlFormatter.normalize("wss://nostr.wine"),
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
)
val DefaultZapAmounts = persistentListOf(100L, 500L, 1000L)
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { codedList.add(it.language) }
}
return codedList
}
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val GLOBAL_FOLLOWS = " Global "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " All Follows "
@Stable
class AccountSettings(
val keyPair: KeyPair,
var externalSignerPackageName: String? = null,
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var localRelayServers: Set<String> = setOf(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>> = MutableStateFlow(DefaultZapAmounts),
var reactionChoices: MutableStateFlow<ImmutableList<String>> = MutableStateFlow(DefaultReactions),
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType> = MutableStateFlow(LnZapEvent.ZapType.PUBLIC),
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
var backupUserMetadata: MetadataEvent? = null,
var backupContactList: ContactListEvent? = null,
var backupDMRelayList: ChatMessageRelayListEvent? = null,
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
var backupSearchRelayList: SearchRelayListEvent? = null,
var backupMuteList: MuteListEvent? = null,
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
var proxy: Proxy? = null,
var proxyPort: Int = 9050,
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
) {
val saveable = MutableStateFlow(AccountSettingsUpdater(this))
fun saveAccountSettings() {
saveable.update { AccountSettingsUpdater(this) }
}
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
fun createSigner() =
if (keyPair.privKey != null) {
NostrSignerInternal(keyPair)
} else {
when (val packageName = externalSignerPackageName) {
null -> NostrSignerInternal(keyPair)
else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toNpub(), packageName))
}
}
// ---
// Zaps and Reactions
// ---
fun changeDefaultZapType(zapType: LnZapEvent.ZapType) {
if (defaultZapType.value != zapType) {
defaultZapType.tryEmit(zapType)
saveAccountSettings()
}
}
fun changeZapAmounts(newAmounts: List<Long>) {
if (zapAmountChoices.value != newAmounts) {
zapAmountChoices.tryEmit(newAmounts.toImmutableList())
saveAccountSettings()
}
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
if (zapPaymentRequest != newServer) {
zapPaymentRequest = newServer
saveAccountSettings()
}
}
fun changeReactionTypes(newTypes: List<String>) {
if (reactionChoices.value != newTypes) {
reactionChoices.tryEmit(newTypes.toImmutableList())
saveAccountSettings()
}
}
// ---
// file servers
// ---
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
if (defaultFileServer != server) {
defaultFileServer = server
saveAccountSettings()
}
}
// ---
// list names
// ---
fun changeDefaultHomeFollowList(name: String) {
if (defaultHomeFollowList.value != name) {
defaultHomeFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultStoriesFollowList(name: String) {
if (defaultStoriesFollowList.value != name) {
defaultStoriesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultNotificationFollowList(name: String) {
if (defaultNotificationFollowList.value != name) {
defaultNotificationFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultDiscoveryFollowList(name: String) {
if (defaultDiscoveryFollowList.value != name) {
defaultDiscoveryFollowList.tryEmit(name)
saveAccountSettings()
}
}
// ---
// proxy settings
// ---
fun isProxyEnabled() = proxy != null
fun updateProxy(
enabled: Boolean,
portNumber: String,
) {
val port = portNumber.toIntOrNull() ?: return
if (proxyPort != port || isProxyEnabled() != enabled) {
proxyPort = portNumber.toInt()
proxy = HttpClientManager.initProxy(enabled, "127.0.0.1", proxyPort)
saveAccountSettings()
}
}
// ---
// language services
// ---
fun addDontTranslateFrom(languageCode: String) {
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
saveAccountSettings()
}
}
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
fun updateTranslateTo(languageCode: Locale) {
if (translateTo != languageCode.language) {
translateTo = languageCode.language
saveAccountSettings()
}
}
fun prefer(
source: String,
target: String,
preference: String,
) {
val key = "$source,$target"
if (key !in languagePreferences) {
languagePreferences = languagePreferences + Pair(key, preference)
saveAccountSettings()
}
}
fun preferenceBetween(
source: String,
target: String,
): String? = languagePreferences["$source,$target"]
// ----
// Backup Lists
// ----
fun updateLocalRelayServers(servers: Set<String>) {
if (localRelayServers != servers) {
localRelayServers = servers
saveAccountSettings()
}
}
fun updateUserMetadata(newMetadata: MetadataEvent?) {
if (newMetadata == null) return
// Events might be different objects, we have to compare their ids.
if (backupUserMetadata?.id != newMetadata.id) {
backupUserMetadata = newMetadata
saveAccountSettings()
}
}
fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList == null || newContactList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList.id) {
backupContactList = newContactList
saveAccountSettings()
}
}
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupDMRelayList?.id != newDMRelayList.id) {
backupDMRelayList = newDMRelayList
saveAccountSettings()
}
}
fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) {
if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupNIP65RelayList?.id != newNIP65RelayList.id) {
backupNIP65RelayList = newNIP65RelayList
saveAccountSettings()
}
}
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupSearchRelayList?.id != newSearchRelayList.id) {
backupSearchRelayList = newSearchRelayList
saveAccountSettings()
}
}
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) {
backupPrivateHomeRelayList = newPrivateHomeRelayList
saveAccountSettings()
}
}
fun updateMuteList(newMuteList: MuteListEvent?) {
if (newMuteList == null || newMuteList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupMuteList?.id != newMuteList.id) {
backupMuteList = newMuteList
saveAccountSettings()
}
}
// ----
// Warning dialogs
// ----
fun setHideDeleteRequestDialog() {
if (!hideDeleteRequestDialog) {
hideDeleteRequestDialog = true
saveAccountSettings()
}
}
fun setHideNIP17WarningDialog() {
if (!hideNIP17WarningDialog) {
hideNIP17WarningDialog = true
saveAccountSettings()
}
}
fun setHideBlockAlertDialog() {
if (!hideBlockAlertDialog) {
hideBlockAlertDialog = true
saveAccountSettings()
}
}
fun updateShowSensitiveContent(show: Boolean?): Boolean {
if (showSensitiveContent.value != show) {
showSensitiveContent.update { show }
saveAccountSettings()
return true
}
return false
}
// ---
// donations
// ---
fun hasDonatedInVersion(versionName: String) = hasDonatedInVersion.value.contains(versionName)
fun observeDonatedInVersion(versionName: String) =
hasDonatedInVersion
.map {
it.contains(versionName)
}
fun markDonatedInThisVersion(versionName: String): Boolean {
if (!hasDonatedInVersion.value.contains(versionName)) {
hasDonatedInVersion.update {
it + versionName
}
saveAccountSettings()
return true
}
return false
}
// ----
// last read flows
// ----
fun getLastReadFlow(route: String): StateFlow<Long> = lastReadPerRoute.value[route] ?: addLastRead(route, 0)
private fun addLastRead(
route: String,
timestampInSecs: Long,
): MutableStateFlow<Long> =
MutableStateFlow<Long>(timestampInSecs).also { newFlow ->
lastReadPerRoute.update { it + Pair(route, newFlow) }
saveAccountSettings()
}
fun markAsRead(
route: String,
timestampInSecs: Long,
): Boolean {
val lastTime = lastReadPerRoute.value[route]
return if (lastTime == null) {
addLastRead(route, timestampInSecs)
true
} else if (timestampInSecs > lastTime.value) {
lastTime.tryEmit(timestampInSecs)
saveAccountSettings()
true
} else {
false
}
}
// ----
// local relays
// ----
fun updateLocalRelays(newLocalRelays: Set<RelaySetupInfo>) {
if (!localRelays.equals(newLocalRelays)) {
localRelays = newLocalRelays
saveAccountSettings()
}
}
// ---
// attestations
// ---
fun addPendingAttestation(
id: HexKey,
stamp: String,
) {
val current = pendingAttestations.value.get(id)
if (current == null) {
pendingAttestations.update {
it + Pair(id, stamp)
}
saveAccountSettings()
} else {
if (current != stamp) {
pendingAttestations.update {
it + Pair(id, stamp)
}
saveAccountSettings()
}
}
}
// ---
// filters
// ---
fun updateOptOutOptions(
warnReports: Boolean,
filterSpam: Boolean,
): Boolean =
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) {
warnAboutPostsWithReports = warnReports
filterSpamFromStrangers = filterSpam
saveAccountSettings()
true
} else {
false
}
}
class AccountSettingsUpdater(
val accountSettings: AccountSettings,
)

View File

@@ -22,17 +22,19 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayStats
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
data class Spammer(
val pubkeyHex: HexKey,
var duplicatedMessages: Set<HexKey>,
)
class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
@@ -69,10 +71,6 @@ class AntiSpamFilter {
if (
(recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null
) {
Log.w(
"Potential SPAM Message for sharing",
"${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, null)}",
)
Log.w(
"Potential SPAM Message",
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
@@ -81,7 +79,11 @@ class AntiSpamFilter {
// Log down offenders
logOffender(hash, event)
liveSpam.invalidateData()
if (relay != null) {
RelayStats.newSpam(relay.url, njumpLink(Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)))
}
flowSpam.tryEmit(AntiSpamState(this))
return true
}
@@ -100,29 +102,13 @@ class AntiSpamFilter {
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
} else {
val spammer = spamMessages.get(hashCode)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
spammer.duplicatedMessages += event.id
}
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
val flowSpam = MutableStateFlow<AntiSpamState>(AntiSpamState(this))
}
@Stable
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(AntiSpamState(cache))
}
}
}
}
class AntiSpamState(val cache: AntiSpamFilter)
class AntiSpamState(
val cache: AntiSpamFilter,
)

View File

@@ -25,9 +25,9 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
@@ -37,7 +37,9 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
@Stable
class PublicChatChannel(idHex: String) : Channel(idHex) {
class PublicChatChannel(
idHex: String,
) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(
@@ -49,26 +51,22 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
return info.name ?: super.toBestDisplayName()
}
override fun toBestDisplayName(): String = info.name ?: super.toBestDisplayName()
override fun summary(): String? {
return info.about
}
override fun summary(): String? = info.about
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return super.profilePicture()
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty()
}
override fun anyNameStartsWith(prefix: String): Boolean = listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty()
}
@Stable
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
class LiveActivitiesChannel(
val address: ATag,
) : Channel(address.toTag()) {
var info: LiveActivitiesEvent? = null
override fun idNote() = address.toNAddr()
@@ -86,27 +84,22 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
return info?.title() ?: super.toBestDisplayName()
}
override fun toBestDisplayName(): String = info?.title() ?: super.toBestDisplayName()
override fun summary(): String? {
return info?.summary()
}
override fun summary(): String? = info?.summary()
override fun profilePicture(): String? {
return info?.image()?.ifBlank { null }
}
override fun profilePicture(): String? = info?.image()?.ifBlank { null }
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info?.title(), info?.summary())
override fun anyNameStartsWith(prefix: String): Boolean =
listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) }
.isNotEmpty()
}
}
@Stable
abstract class Channel(val idHex: String) {
abstract class Channel(
val idHex: String,
) {
var creator: User? = null
var updatedMetadataAt: Long = 0
val notes = LargeCache<HexKey, Note>()
@@ -118,21 +111,13 @@ abstract class Channel(val idHex: String) {
open fun idDisplayNote() = idNote().toShortenHex()
open fun toBestDisplayName(): String {
return idDisplayNote()
}
open fun toBestDisplayName(): String = idDisplayNote()
open fun summary(): String? {
return null
}
open fun summary(): String? = null
open fun creatorName(): String? {
return creator?.toBestDisplayName()
}
open fun creatorName(): String? = creator?.toBestDisplayName()
open fun profilePicture(): String? {
return creator?.info?.banner
}
open fun profilePicture(): String? = creator?.info?.banner
open fun updateChannelInfo(
creator: User,
@@ -167,10 +152,10 @@ abstract class Channel(val idHex: String) {
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important =
notes.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == false
}
.sortedWith(DefaultFeedOrder)
notes
.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == false
}.sortedWith(DefaultFeedOrder)
.take(500)
.toSet()
@@ -182,7 +167,9 @@ abstract class Channel(val idHex: String) {
}
}
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
class ChannelLiveData(
val channel: Channel,
) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
@@ -208,4 +195,6 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
}
}
class ChannelState(val channel: Channel)
class ChannelState(
val channel: Channel,
)

View File

@@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class Chatroom() {
class Chatroom {
var authors: Set<User> = setOf()
var roomMessages: Set<Note> = setOf()
var subject: String? = null
@@ -72,12 +74,10 @@ class Chatroom() {
}
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return authors.any { it.pubkeyHex in keySet }
}
fun senderIntersects(keySet: Set<HexKey>): Boolean = authors.any { it.pubkeyHex in keySet }
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val sorted = roomMessages.sortedWith(DefaultFeedOrder)
val toKeep =
if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
@@ -86,7 +86,7 @@ class Chatroom() {
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
} + sorted.filter { it.liveSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep

View File

@@ -68,8 +68,8 @@ fun RenderHashTagIcons() {
}
}
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
return when (tag.lowercase()) {
fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
when (tag.lowercase()) {
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
"lightning", "lightningnetwork" -> lightning
@@ -86,7 +86,6 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
else -> null
}
}
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))

View File

@@ -26,9 +26,11 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.data.DeletionIndex
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.HexValidator
@@ -56,6 +58,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
@@ -88,21 +91,31 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NIP90StatusEvent
import com.vitorpamplona.quartz.events.NIP90UserDiscoveryRequestEvent
import com.vitorpamplona.quartz.events.NIP90UserDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NNSEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RecommendRelayEvent
import com.vitorpamplona.quartz.events.RelaySetEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
@@ -113,10 +126,12 @@ import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -136,6 +151,77 @@ object LocalCache {
val deletionIndex = DeletionIndex()
val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
fun <T : Event> observeETag(
kind: Int,
eventId: HexKey,
scope: CoroutineScope,
): LatestByKindWithETag<T> {
var eTagList = observablesByKindAndETag.get(kind)
if (eTagList == null) {
eTagList = ConcurrentHashMap<HexKey, LatestByKindWithETag<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>
observablesByKindAndETag.put(kind, eTagList)
}
val value = eTagList.get(eventId)
return if (value != null) {
value
} else {
val newObject = LatestByKindWithETag<T>(kind, eventId) as LatestByKindWithETag<Event>
val obj = eTagList.putIfAbsent(eventId, newObject) ?: newObject
if (obj == newObject) {
// initialize
scope.launch(Dispatchers.IO) {
obj.init()
}
}
obj
} as LatestByKindWithETag<T>
}
fun <T : Event> observeAuthor(
kind: Int,
pubkey: HexKey,
scope: CoroutineScope,
): LatestByKindAndAuthor<T> {
var authorObsList = observablesByKindAndAuthor.get(kind)
if (authorObsList == null) {
authorObsList = ConcurrentHashMap<HexKey, LatestByKindAndAuthor<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>
observablesByKindAndAuthor.put(kind, authorObsList)
}
val value = authorObsList.get(pubkey)
return if (value != null) {
value
} else {
val newObject = LatestByKindAndAuthor<T>(kind, pubkey) as LatestByKindAndAuthor<Event>
val obj = authorObsList.putIfAbsent(pubkey, newObject) ?: newObject
if (obj == newObject) {
// initialize
scope.launch(Dispatchers.IO) {
obj.init()
}
}
obj
} as LatestByKindAndAuthor<T>
}
private fun updateObservables(event: Event) {
observablesByKindAndETag[event.kind]?.let { observablesOfKind ->
event.forEachTaggedEvent {
observablesOfKind[it]?.updateIfMatches(event)
}
}
observablesByKindAndAuthor[event.kind]?.get(event.pubKey)?.updateIfMatches(event)
}
fun checkGetOrCreateUser(key: String): User? {
// checkNotInMainThread()
@@ -159,33 +245,25 @@ object LocalCache {
return users.get(key)
}
fun getAddressableNoteIfExists(key: String): AddressableNote? {
return addressables.get(key)
}
fun getAddressableNoteIfExists(key: String): AddressableNote? = addressables.get(key)
fun getNoteIfExists(key: String): Note? {
return addressables.get(key) ?: notes.get(key)
}
fun getNoteIfExists(key: String): Note? = addressables.get(key) ?: notes.get(key)
fun getChannelIfExists(key: String): Channel? {
return channels.get(key)
}
fun getChannelIfExists(key: String): Channel? = channels.get(key)
fun getNoteIfExists(event: Event): Note? {
return if (event is AddressableEvent) {
fun getNoteIfExists(event: Event): Note? =
if (event is AddressableEvent) {
getAddressableNoteIfExists(event.addressTag())
} else {
getNoteIfExists(event.id)
}
}
fun getOrCreateNote(event: Event): Note {
return if (event is AddressableEvent) {
fun getOrCreateNote(event: Event): Note =
if (event is AddressableEvent) {
getOrCreateAddressableNote(event.address())
} else {
getOrCreateNote(event.id)
}
}
fun checkGetOrCreateNote(key: String): Note? {
checkNotInMainThread()
@@ -266,8 +344,8 @@ object LocalCache {
return HexValidator.isHex(key)
}
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? {
return try {
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? =
try {
val addr = ATag.parse(key, null) // relay doesn't matter for the index.
if (addr != null) {
getOrCreateAddressableNote(addr)
@@ -278,7 +356,6 @@ object LocalCache {
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
null
}
}
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
// checkNotInMainThread()
@@ -313,6 +390,10 @@ object LocalCache {
val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null) {
oldUser.updateUserInfo(newUserMetadata, event)
if (relay != null) {
oldUser.addRelayBeingUsed(relay, event.createdAt)
oldUser.latestMetadataRelay = relay.url
}
}
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex()} ${oldUser.toBestDisplayName()} from ${relay?.url}")
} else {
@@ -328,6 +409,8 @@ object LocalCache {
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
user.updateContactList(event)
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
updateObservables(event)
}
}
@@ -344,11 +427,11 @@ object LocalCache {
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp)
fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
}
fun consume(
event: TextNoteEvent,
@@ -366,7 +449,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -383,6 +465,203 @@ object LocalCache {
refreshObservers(note)
}
fun consume(
event: TorrentEvent,
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
if (antiSpam.isSpam(event, relay)) {
return
}
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(
event: TorrentCommentEvent,
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
if (antiSpam.isSpam(event, relay)) {
return
}
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers(note)
}
fun consume(
event: NIP90ContentDiscoveryResponseEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Log.d("TN", "New Response ${event.taggedEvents().joinToString(", ") { it }}}")
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: NIP90ContentDiscoveryRequestEvent,
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
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: NIP90StatusEvent,
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
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: NIP90UserDiscoveryResponseEvent,
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
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: NIP90UserDiscoveryRequestEvent,
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
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: GitPatchEvent,
relay: Relay? = null,
@@ -399,7 +678,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -424,7 +702,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -449,7 +726,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -487,7 +763,6 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -522,7 +797,6 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -535,8 +809,8 @@ object LocalCache {
}
}
fun computeReplyTo(event: Event): List<Note> {
return when (event) {
fun computeReplyTo(event: Event): List<Note> =
when (event) {
is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is WikiNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
@@ -578,14 +852,15 @@ object LocalCache {
.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
is TorrentCommentEvent ->
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is DraftEvent -> {
event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().mapNotNull { checkGetOrCreateAddressableNote(it.toTag()) }
event.mapTaggedEvent { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) }
}
else -> emptyList<Note>()
}
}
fun consume(
event: PollNoteEvent,
@@ -603,7 +878,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -699,6 +973,27 @@ object LocalCache {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: ChatMessageRelayListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: PrivateOutboxRelayListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: SearchRelayListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: CommunityDefinitionEvent,
relay: Relay?,
@@ -978,9 +1273,23 @@ object LocalCache {
if (deletionIndex.add(event)) {
var deletedAtLeastOne = false
event.deleteEvents()
event
.deleteEvents()
.mapNotNull { getNoteIfExists(it) }
.forEach { deleteNote ->
val deleteNoteEvent = deleteNote.event
if (deleteNoteEvent is AddressableEvent) {
val addressableNote = getAddressableNoteIfExists(deleteNoteEvent.addressTag())
if (addressableNote?.author?.pubkeyHex == event.pubKey && (addressableNote.createdAt() ?: 0) <= event.createdAt) {
// Counts the replies
deleteNote(addressableNote)
addressables.remove(addressableNote.idHex)
deletedAtLeastOne = true
}
}
// must be the same author
if (deleteNote.author?.pubkeyHex == event.pubKey) {
// reverts the add
@@ -1053,6 +1362,10 @@ object LocalCache {
getChannelIfExists(it.toTag())?.removeNote(deleteNote)
}
(deletedEvent as? TorrentCommentEvent)?.torrent()?.let {
getNoteIfExists(it)?.removeReply(deleteNote)
}
if (deletedEvent is PrivateDmEvent) {
val author = deleteNote.author
val recipient =
@@ -1078,6 +1391,9 @@ object LocalCache {
deleteWraps(deletedEvent)
}
deleteNote.clearFlow()
deleteNote.clearLive()
notes.remove(deleteNote.idHex)
}
@@ -1089,7 +1405,10 @@ object LocalCache {
if (noteEvent is WrappedEvent) {
deleteWraps(noteEvent)
}
it.clearFlow()
it.clearLive()
}
notes.remove(it.id)
}
}
@@ -1288,7 +1607,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -1327,7 +1645,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -1347,7 +1664,10 @@ object LocalCache {
@Suppress("UNUSED_PARAMETER")
fun consume(event: ChannelMuteUserEvent) {}
fun consume(event: LnZapEvent) {
fun consume(
event: LnZapEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
@@ -1435,6 +1755,26 @@ object LocalCache {
refreshObservers(note)
}
fun consume(
event: ProfileGalleryEntryEvent,
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?,
@@ -1607,7 +1947,7 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(
fun consume(
event: SealedGossipEvent,
relay: Relay?,
) {
@@ -1622,7 +1962,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
note.loadEvent(event.copyNoContent(), author, emptyList())
refreshObservers(note)
}
@@ -1642,7 +1982,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
note.loadEvent(event.copyNoContent(), author, emptyList())
refreshObservers(note)
}
@@ -1792,16 +2132,16 @@ object LocalCache {
suspend fun findStatusesForUser(user: User): ImmutableList<AddressableNote> {
checkNotInMainThread()
return addressables.filter { _, it ->
val noteEvent = it.event
(
noteEvent is StatusEvent &&
noteEvent.pubKey == user.pubkeyHex &&
!noteEvent.isExpired() &&
noteEvent.content.isNotBlank()
)
}
.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex }))
return addressables
.filter { _, it ->
val noteEvent = it.event
(
noteEvent is StatusEvent &&
noteEvent.pubKey == user.pubkeyHex &&
!noteEvent.isExpired() &&
noteEvent.content.isNotBlank()
)
}.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex }))
.reversed()
.toImmutableList()
}
@@ -1828,9 +2168,7 @@ object LocalCache {
val modificationCache = LruCache<HexKey, List<Note>>(20)
fun cachedModificationEventsForNote(note: Note): List<Note>? {
return modificationCache[note.idHex]
}
fun cachedModificationEventsForNote(note: Note): List<Note>? = modificationCache[note.idHex]
suspend fun findLatestModificationForNote(note: Note): List<Note> {
checkNotInMainThread()
@@ -1844,11 +2182,12 @@ object LocalCache {
val time = TimeUtils.now()
val newNotes =
notes.filter { _, item ->
val noteEvent = item.event
notes
.filter { _, item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
noteEvent is TextNoteModificationEvent && note.author == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
modificationCache.put(note.idHex, newNotes)
@@ -1859,6 +2198,26 @@ object LocalCache {
notes.forEach { _, it -> it.clearLive() }
addressables.forEach { _, it -> it.clearLive() }
users.forEach { _, it -> it.clearLive() }
notes.forEach { _, it -> it.clearFlow() }
addressables.forEach { _, it -> it.clearFlow() }
users.forEach { _, it -> it.clearFlow() }
observablesByKindAndETag.forEach { _, list ->
list.forEach { key, value ->
if (value.canDelete()) {
list.remove(key)
}
}
}
observablesByKindAndAuthor.forEach { _, list ->
list.forEach { key, value ->
if (value.canDelete()) {
list.remove(key)
}
}
}
}
fun pruneOldAndHiddenMessages(account: Account) {
@@ -1891,6 +2250,8 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
// TODO: NEED TO TEST IF WRAPS COME BACK WHEN NEEDED BEFORE ACTIVATING
// childrenToBeRemoved.addAll(removeIfWrap(it))
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
@@ -1900,13 +2261,31 @@ object LocalCache {
if (toBeRemoved.size > 1) {
println(
"PRUNE: ${toBeRemoved.size} private messages with ${user.toBestDisplayName()} removed. ${it.roomMessages.size} kept",
"PRUNE: ${toBeRemoved.size} private messages from ${user.toBestDisplayName()} to ${it.authors.joinToString(", ") { it.toBestDisplayName() }} removed. ${it.roomMessages.size} kept",
)
}
}
}
}
fun removeIfWrap(note: Note): List<Note> {
val noteEvent = note.event
val children =
if (noteEvent is WrappedEvent) {
noteEvent.host?.id?.let {
getNoteIfExists(it)?.let {
removeFromCache(it)
it.removeAllChildNotes()
}
}
} else {
null
}
return children ?: emptyList()
}
fun prunePastVersionsOfReplaceables() {
val toBeRemoved =
notes.filter { _, note ->
@@ -1952,9 +2331,11 @@ object LocalCache {
note.event is GenericRepostEvent
) &&
note.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
note.liveSet?.isInUse() != true && // don't delete if observing.
note.liveSet?.isInUse() != true &&
// don't delete if observing.
note.author?.pubkeyHex !in
accounts && // don't delete if it is the logged in account
accounts &&
// don't delete if it is the logged in account
note.event?.isTaggedUsers(accounts) !=
true // don't delete if it's a notification to the logged in user
}
@@ -2013,6 +2394,9 @@ object LocalCache {
}
}
note.clearFlow()
note.clearLive()
notes.remove(note.idHex)
}
@@ -2050,8 +2434,7 @@ object LocalCache {
?.hiddenUsers
?.map { userHex ->
(notes.filter { _, it -> it.event?.pubKey() == userHex } + addressables.filter { _, it -> it.event?.pubKey() == userHex }).toSet()
}
?.flatten()
}?.flatten()
?: emptyList()
toBeRemoved.forEach {
@@ -2083,9 +2466,10 @@ object LocalCache {
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData()
val live: LocalCacheFlow = LocalCacheFlow()
private fun refreshObservers(newNote: Note) {
updateObservables(newNote.event as Event)
live.invalidateData(newNote)
}
@@ -2114,7 +2498,7 @@ object LocalCache {
}
}
private fun consume(
fun consume(
event: DraftEvent,
relay: Relay?,
) {
@@ -2230,7 +2614,28 @@ object LocalCache {
event: Event,
relay: Relay?,
) {
if (deletionIndex.hasBeenDeleted(event)) return
if (deletionIndex.hasBeenDeleted(event)) {
// update relay with deletion event from another.
if (relay != null) {
deletionIndex.hasBeenDeletedBy(event)?.let {
Log.d("LocalCache", "Updating ${relay.url} with a Deletion Event ${it.toJson()} because of ${event.toJson()}")
relay.send(it)
}
}
return
}
if (event is AddressableEvent && relay != null) {
// updates relay with a new event.
getAddressableNoteIfExists(event.addressTag())?.let {
it.event?.let { existingEvent ->
if (existingEvent.createdAt() > event.createdAt) {
Log.d("LocalCache", "Updating ${relay.url} with a new version of ${event.toJson()} to ${existingEvent.toJson()}")
relay.send(existingEvent)
}
}
}
}
checkNotInMainThread()
@@ -2256,6 +2661,7 @@ object LocalCache {
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ChatMessageEvent -> consume(event, relay)
is ChatMessageRelayListEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event, relay)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityListEvent -> consume(event, relay)
@@ -2268,9 +2674,13 @@ object LocalCache {
is DraftEvent -> consume(event, relay)
is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay)
is SealedGossipEvent -> consume(event, relay)
is GenericRepostEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) }
consume(event)
}
is FhirResourceEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
is ProfileGalleryEntryEvent -> consume(event, relay)
is FileServersEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
@@ -2286,10 +2696,15 @@ object LocalCache {
event.zapRequest?.let {
// must have a valid request
verifyAndConsume(it, relay)
consume(event)
consume(event, relay)
}
}
is LnZapRequestEvent -> consume(event)
is NIP90StatusEvent -> consume(event, relay)
is NIP90ContentDiscoveryResponseEvent -> consume(event, relay)
is NIP90ContentDiscoveryRequestEvent -> consume(event, relay)
is NIP90UserDiscoveryResponseEvent -> consume(event, relay)
is NIP90UserDiscoveryRequestEvent -> consume(event, relay)
is LnZapPaymentRequestEvent -> consume(event)
is LnZapPaymentResponseEvent -> consume(event)
is LongTextNoteEvent -> consume(event, relay)
@@ -2298,6 +2713,7 @@ object LocalCache {
is NNSEvent -> comsume(event, relay)
is OtsEvent -> consume(event, relay)
is PrivateDmEvent -> consume(event, relay)
is PrivateOutboxRelayListEvent -> consume(event, relay)
is PinListEvent -> consume(event, relay)
is PeopleListEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
@@ -2309,13 +2725,13 @@ object LocalCache {
event.containedPost()?.let { verifyAndConsume(it, relay) }
consume(event)
}
is GenericRepostEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) }
consume(event)
}
is SealedGossipEvent -> consume(event, relay)
is SearchRelayListEvent -> consume(event, relay)
is StatusEvent -> consume(event, relay)
is TextNoteEvent -> consume(event, relay)
is TextNoteModificationEvent -> consume(event, relay)
is TorrentEvent -> consume(event, relay)
is TorrentCommentEvent -> consume(event, relay)
is VideoHorizontalEvent -> consume(event, relay)
is VideoVerticalEvent -> consume(event, relay)
is WikiNoteEvent -> consume(event, relay)
@@ -2329,8 +2745,8 @@ object LocalCache {
}
}
fun hasConsumed(notificationEvent: Event): Boolean {
return if (notificationEvent is AddressableEvent) {
fun hasConsumed(notificationEvent: Event): Boolean =
if (notificationEvent is AddressableEvent) {
val note = addressables.get(notificationEvent.addressTag())
val noteEvent = note?.event
noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt()
@@ -2338,11 +2754,10 @@ object LocalCache {
val note = notes.get(notificationEvent.id)
note?.event != null
}
}
}
@Stable
class LocalCacheLiveData {
class LocalCacheFlow {
private val _newEventBundles = MutableSharedFlow<Set<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
@@ -2350,8 +2765,7 @@ class LocalCacheLiveData {
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) {
bundledNewNotes ->
bundler.invalidateList(newNote) { bundledNewNotes ->
_newEventBundles.emit(bundledNewNotes)
}
}

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.databind.DeserializationFeature
@@ -40,11 +41,13 @@ import kotlinx.collections.immutable.toImmutableMap
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
)
@Stable
open class Resource(
var resourceType: String? = null,
var id: String = "",
)
@Stable
class Practitioner(
resourceType: String? = null,
id: String = "",
@@ -53,6 +56,7 @@ class Practitioner(
var gender: String? = null,
) : Resource(resourceType, id)
@Stable
class Patient(
resourceType: String? = null,
id: String = "",
@@ -61,16 +65,16 @@ class Patient(
var gender: String? = null,
) : Resource(resourceType, id)
@Stable
class HumanName(
var use: String? = null,
var family: String? = null,
var given: ArrayList<String> = arrayListOf(),
) {
fun assembleName(): String {
return given.joinToString(" ") + " " + family
}
fun assembleName(): String = given.joinToString(" ") + " " + family
}
@Stable
class Bundle(
resourceType: String? = null,
id: String = "",
@@ -79,6 +83,7 @@ class Bundle(
var entry: List<Resource> = arrayListOf(),
) : Resource(resourceType, id)
@Stable
class VisionPrescription(
resourceType: String? = null,
id: String = "",
@@ -91,6 +96,7 @@ class VisionPrescription(
var lensSpecification: List<LensSpecification> = arrayListOf(),
) : Resource(resourceType, id)
@Stable
class LensSpecification(
var eye: String? = null,
var sphere: Double? = null,
@@ -107,6 +113,7 @@ class LensSpecification(
var note: String? = null,
)
@Stable
class Prism(
var amount: Double? = null,
var base: String? = null,

View File

@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
@@ -29,12 +28,13 @@ import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.actions.relays.updated
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
@@ -71,13 +71,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
class AddressableNote(
val address: ATag,
) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun toNEvent() = address.toNAddr()
@@ -95,9 +94,7 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
return minOf(publishedAt, lastCreatedAt)
}
fun dTag(): String? {
return (event as? AddressableEvent)?.dTag()
}
fun dTag(): String? = (event as? AddressableEvent)?.dTag()
override fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
@@ -109,7 +106,9 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
}
@Stable
open class Note(val idHex: String) {
open class Note(
val idHex: String,
) {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: EventInterface? = null
@@ -154,7 +153,7 @@ open class Note(val idHex: String) {
Nip19Bech32.createNEvent(
host.id,
host.pubKey,
host.kind(),
host.kind,
relays.firstOrNull()?.url,
)
} else {
@@ -165,14 +164,12 @@ open class Note(val idHex: String) {
}
}
fun toNostrUri(): String {
return "nostr:${toNEvent()}"
}
fun toNostrUri(): String = "nostr:${toNEvent()}"
open fun idDisplayNote() = idNote().toShortenHex()
fun channelHex(): HexKey? {
return if (
fun channelHex(): HexKey? =
if (
event is ChannelMessageEvent ||
event is ChannelMetadataEvent ||
event is ChannelCreateEvent ||
@@ -187,7 +184,6 @@ open class Note(val idHex: String) {
} else {
null
}
}
open fun address(): ATag? = null
@@ -210,96 +206,6 @@ open class Note(val idHex: String) {
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
eventsToConsider: Set<HexKey>,
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long,
): LevelSignature {
val replyTo = replyTo
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
createdAt = createdAt(),
author = author,
)
}
val parent =
(
replyTo
.filter {
it.idHex in eventsToConsider
} // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it]
?: it
.replyLevelSignature(
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now,
)
.apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (parent?.author == author && createdAt() != null) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) +
idHex.substring(0, 8)
} else if (author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
} else if (author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = createdAt(),
author = author,
)
cachedSignatures[this] = mySignature
return mySignature
}
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
val replyTo = replyTo
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return 0
}
return replyTo.maxOf {
cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
}
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
@@ -351,7 +257,9 @@ open class Note(val idHex: String) {
if (repliesChanged) liveSet?.innerReplies?.invalidateData()
if (reactionsChanged) liveSet?.innerReactions?.invalidateData()
if (boostsChanged) liveSet?.innerBoosts?.invalidateData()
if (reportsChanged) liveSet?.innerReports?.invalidateData()
if (reportsChanged) {
flowSet?.reports?.invalidateData()
}
if (zapsChanged) liveSet?.innerZaps?.invalidateData()
return toBeRemoved
@@ -383,7 +291,7 @@ open class Note(val idHex: String) {
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.innerReports?.invalidateData()
flowSet?.reports?.invalidateData()
}
}
}
@@ -492,10 +400,10 @@ open class Note(val idHex: String) {
if (reportsByAuthor == null) {
reports = reports + Pair(author, listOf(note))
liveSet?.innerReports?.invalidateData()
flowSet?.reports?.invalidateData()
} else if (!reportsByAuthor.contains(note)) {
reports = reports + Pair(author, reportsByAuthor + note)
liveSet?.innerReports?.invalidateData()
flowSet?.reports?.invalidateData()
}
}
@@ -509,7 +417,7 @@ open class Note(val idHex: String) {
fun addRelay(relay: Relay) {
if (relay.brief !in relays) {
addRelaySync(relay.brief)
liveSet?.innerRelays?.invalidateData()
flowSet?.relays?.invalidateData()
}
}
@@ -615,39 +523,30 @@ open class Note(val idHex: String) {
isZappedByCalculation(option, user, account, zaps, onWasZappedByAuthor)
}
fun getReactionBy(user: User): String? {
return reactions.firstNotNullOfOrNull {
fun getReactionBy(user: User): String? =
reactions.firstNotNullOfOrNull {
if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
it.key
} else {
null
}
}
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun isBoostedBy(user: User): Boolean = boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
fun hasReportsBy(user: User): Boolean {
return reports[user]?.isNotEmpty() ?: false
}
fun hasReportsBy(user: User): Boolean = reports[user]?.isNotEmpty() ?: false
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.count { it.key.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<HexKey>): Int = reports.count { it.key.pubkeyHex in users }
fun reportsBy(users: Set<HexKey>): List<Note> {
return reports
fun reportsBy(users: Set<HexKey>): List<Note> =
reports
.mapNotNull {
if (it.key.pubkeyHex in users) {
it.value
} else {
null
}
}
.flatten()
}
}.flatten()
private fun updateZapTotal() {
var sumOfAmounts = BigDecimal.ZERO
@@ -727,8 +626,8 @@ open class Note(val idHex: String) {
)
}
fun hasPledgeBy(user: User): Boolean {
return replies
fun hasPledgeBy(user: User): Boolean =
replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.any {
val pledgeValue =
@@ -742,10 +641,9 @@ open class Note(val idHex: String) {
pledgeValue != null && it.author == user
}
}
fun pledgedAmountByOthers(): BigDecimal {
return replies
fun pledgedAmountByOthers(): BigDecimal =
replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.mapNotNull {
try {
@@ -755,9 +653,7 @@ open class Note(val idHex: String) {
null
// do nothing if it can't convert to bigdecimal
}
}
.sumOf { it }
}
}.sumOf { it }
fun hasAnyReports(): Boolean {
val dayAgo = TimeUtils.oneDayAgo()
@@ -768,8 +664,8 @@ open class Note(val idHex: String) {
)
}
fun isNewThread(): Boolean {
return (
fun isNewThread(): Boolean =
(
event is RepostEvent ||
event is GenericRepostEvent ||
replyTo == null ||
@@ -777,29 +673,20 @@ open class Note(val idHex: String) {
) &&
event !is ChannelMessageEvent &&
event !is LiveActivitiesChatMessageEvent
}
fun hasZapped(loggedIn: User): Boolean {
return zaps.any { it.key.author == loggedIn }
}
fun hasZapped(loggedIn: User): Boolean = zaps.any { it.key.author == loggedIn }
fun hasReacted(
loggedIn: User,
content: String,
): Boolean {
return reactedBy(loggedIn, content).isNotEmpty()
}
): Boolean = reactedBy(loggedIn, content).isNotEmpty()
fun reactedBy(
loggedIn: User,
content: String,
): List<Note> {
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
}
): List<Note> = reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
fun reactedBy(loggedIn: User): List<String> {
return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
}
fun reactedBy(loggedIn: User): List<String> = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
return boosts.firstOrNull {
@@ -807,9 +694,7 @@ open class Note(val idHex: String) {
} != null // 5 minute protection
}
fun boostedBy(loggedIn: User): List<Note> {
return boosts.filter { it.author == loggedIn }
}
fun boostedBy(loggedIn: User): List<Note> = boosts.filter { it.author == loggedIn }
fun moveAllReferencesTo(note: AddressableNote) {
// migrates these comments to a new version
@@ -854,33 +739,38 @@ open class Note(val idHex: String) {
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
val thisEvent = event ?: return false
val hash = thisEvent.pubKey().hashCode()
val isBoostedNoteHidden =
if (
thisEvent is GenericRepostEvent ||
thisEvent is RepostEvent ||
thisEvent is CommunityPostApprovalEvent
) {
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
} else {
false
// if the author is hidden by spam or blocked
if (accountChoices.hiddenUsersHashCodes.contains(hash) ||
accountChoices.spammersHashCodes.contains(hash)
) {
return true
}
// if the post is sensitive and the user doesn't want to see sensitive content
if (accountChoices.showSensitiveContent == false && thisEvent.isSensitive()) {
return true
}
// if this is a repost, consider the inner event.
if (
thisEvent is GenericRepostEvent ||
thisEvent is RepostEvent ||
thisEvent is CommunityPostApprovalEvent
) {
if (replyTo?.lastOrNull()?.isHiddenFor(accountChoices) == true) {
return true
}
}
val isHiddenByWord =
if (thisEvent is BaseTextNoteEvent) {
accountChoices.hiddenWords.any {
thisEvent.content.containsAny(accountChoices.hiddenWordsCase)
}
} else {
false
if (thisEvent is BaseTextNoteEvent) {
if (accountChoices.hiddenWordsCase.isNotEmpty() && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) {
return true
}
}
val isSensitive = thisEvent.isSensitive()
return isBoostedNoteHidden ||
isHiddenByWord ||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
accountChoices.spammers.contains(author?.pubkeyHex) ||
(isSensitive && accountChoices.showSensitiveContent == false)
return false
}
var liveSet: NoteLiveSet? = null
@@ -950,28 +840,35 @@ open class Note(val idHex: String) {
}
@Stable
class NoteFlowSet(u: Note) {
class NoteFlowSet(
u: Note,
) {
// Observers line up here.
val metadata = NoteBundledRefresherFlow(u)
val reports = NoteBundledRefresherFlow(u)
val relays = NoteBundledRefresherFlow(u)
fun isInUse(): Boolean {
return metadata.stateFlow.subscriptionCount.value > 0
}
fun isInUse(): Boolean =
metadata.stateFlow.subscriptionCount.value > 0 ||
reports.stateFlow.subscriptionCount.value > 0 ||
relays.stateFlow.subscriptionCount.value > 0
fun destroy() {
metadata.destroy()
reports.destroy()
relays.destroy()
}
}
@Stable
class NoteLiveSet(u: Note) {
class NoteLiveSet(
u: Note,
) {
// Observers line up here.
val innerMetadata = NoteBundledRefresherLiveData(u)
val innerReactions = NoteBundledRefresherLiveData(u)
val innerBoosts = NoteBundledRefresherLiveData(u)
val innerReplies = NoteBundledRefresherLiveData(u)
val innerReports = NoteBundledRefresherLiveData(u)
val innerRelays = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val innerOts = NoteBundledRefresherLiveData(u)
val innerModifications = NoteBundledRefresherLiveData(u)
@@ -980,8 +877,6 @@ class NoteLiveSet(u: Note) {
val reactions = innerReactions.map { it }
val boosts = innerBoosts.map { it }
val replies = innerReplies.map { it }
val reports = innerReports.map { it }
val relays = innerRelays.map { it }
val zaps = innerZaps.map { it }
val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged()
@@ -993,8 +888,7 @@ class NoteLiveSet(u: Note) {
?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}
.distinctUntilChanged()
}.distinctUntilChanged()
val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged()
@@ -1004,22 +898,17 @@ class NoteLiveSet(u: Note) {
var total = 0
it.note.reactions.forEach { total += it.value.size }
total
}
.distinctUntilChanged()
}.distinctUntilChanged()
val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged()
val relayInfo = innerRelays.map { it.note.relays }
val content = innerMetadata.map { it.note.event?.content() ?: "" }
fun isInUse(): Boolean {
return metadata.hasObservers() ||
fun isInUse(): Boolean =
metadata.hasObservers() ||
reactions.hasObservers() ||
boosts.hasObservers() ||
replies.hasObservers() ||
reports.hasObservers() ||
relays.hasObservers() ||
zaps.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
@@ -1028,15 +917,12 @@ class NoteLiveSet(u: Note) {
boostCount.hasObservers() ||
innerOts.hasObservers() ||
innerModifications.hasObservers()
}
fun destroy() {
innerMetadata.destroy()
innerReactions.destroy()
innerBoosts.destroy()
innerReplies.destroy()
innerReports.destroy()
innerRelays.destroy()
innerZaps.destroy()
innerOts.destroy()
innerModifications.destroy()
@@ -1044,8 +930,11 @@ class NoteLiveSet(u: Note) {
}
@Stable
class NoteBundledRefresherFlow(val note: Note) {
class NoteBundledRefresherFlow(
val note: Note,
) {
// Refreshes observers in batches.
// TODO: Replace the bundler for .sample
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(NoteState(note))
@@ -1065,7 +954,9 @@ class NoteBundledRefresherFlow(val note: Note) {
}
@Stable
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
class NoteBundledRefresherLiveData(
val note: Note,
) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -1092,7 +983,10 @@ class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteSta
}
@Stable
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
class NoteLoadingLiveData<Y>(
val note: Note,
initialValue: Y?,
) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
if (note is AddressableNote) {
@@ -1112,25 +1006,6 @@ class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveDat
}
}
@Immutable class NoteState(val note: Note)
object RelayBriefInfoCache {
val cache = LruCache<String, RelayBriefInfo?>(50)
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String =
url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern(),
)
fun get(url: String): RelayBriefInfo {
val info = cache[url]
if (info != null) return info
val newInfo = RelayBriefInfo(url)
cache.put(url, newInfo)
return newInfo
}
}
@Immutable class NoteState(
val note: Note,
)

View File

@@ -34,17 +34,20 @@ data class Settings(
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
val dontShowPushNotificationSelector: Boolean = false,
val dontAskForNotificationPermissions: Boolean = false,
val featureSet: FeatureSetType = FeatureSetType.COMPLETE,
val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED,
)
enum class ThemeType(val screenCode: Int, val resourceId: Int) {
enum class ThemeType(
val screenCode: Int,
val resourceId: Int,
) {
SYSTEM(0, R.string.system),
LIGHT(1, R.string.light),
DARK(2, R.string.dark),
}
fun parseThemeType(code: Int?): ThemeType {
return when (code) {
fun parseThemeType(code: Int?): ThemeType =
when (code) {
ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM
ThemeType.LIGHT.screenCode -> ThemeType.LIGHT
ThemeType.DARK.screenCode -> ThemeType.DARK
@@ -52,21 +55,28 @@ fun parseThemeType(code: Int?): ThemeType {
ThemeType.SYSTEM
}
}
}
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) {
enum class ConnectivityType(
val prefCode: Boolean?,
val screenCode: Int,
val resourceId: Int,
) {
ALWAYS(null, 0, R.string.connectivity_type_always),
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only),
WIFI_ONLY(true, 1, R.string.connectivity_type_unmetered_wifi_only),
NEVER(false, 2, R.string.connectivity_type_never),
}
enum class FeatureSetType(val screenCode: Int, val resourceId: Int) {
enum class FeatureSetType(
val screenCode: Int,
val resourceId: Int,
) {
COMPLETE(0, R.string.ui_feature_set_type_complete),
SIMPLIFIED(1, R.string.ui_feature_set_type_simplified),
PERFORMANCE(2, R.string.ui_feature_set_type_performance),
}
fun parseConnectivityType(code: Boolean?): ConnectivityType {
return when (code) {
fun parseConnectivityType(code: Boolean?): ConnectivityType =
when (code) {
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER
@@ -74,10 +84,9 @@ fun parseConnectivityType(code: Boolean?): ConnectivityType {
ConnectivityType.ALWAYS
}
}
}
fun parseConnectivityType(screenCode: Int): ConnectivityType {
return when (screenCode) {
fun parseConnectivityType(screenCode: Int): ConnectivityType =
when (screenCode) {
ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER
@@ -85,39 +94,40 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType {
ConnectivityType.ALWAYS
}
}
}
fun parseFeatureSetType(screenCode: Int): FeatureSetType {
return when (screenCode) {
fun parseFeatureSetType(screenCode: Int): FeatureSetType =
when (screenCode) {
FeatureSetType.COMPLETE.screenCode -> FeatureSetType.COMPLETE
FeatureSetType.SIMPLIFIED.screenCode -> FeatureSetType.SIMPLIFIED
FeatureSetType.PERFORMANCE.screenCode -> FeatureSetType.PERFORMANCE
else -> {
FeatureSetType.COMPLETE
}
}
}
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
enum class BooleanType(
val prefCode: Boolean?,
val screenCode: Int,
val reourceId: Int,
) {
ALWAYS(null, 0, R.string.connectivity_type_always),
NEVER(false, 1, R.string.connectivity_type_never),
}
fun parseBooleanType(code: Boolean?): BooleanType {
return when (code) {
fun parseBooleanType(code: Boolean?): BooleanType =
when (code) {
BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS
BooleanType.NEVER.prefCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}
fun parseBooleanType(screenCode: Int): BooleanType {
return when (screenCode) {
fun parseBooleanType(screenCode: Int): BooleanType =
when (screenCode) {
BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS
BooleanType.NEVER.screenCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}

View File

@@ -62,8 +62,7 @@ class ThreadAssembler {
} else {
null
}
}
?.filterNotNull()
}?.filterNotNull()
if (roots != null && roots.isNotEmpty()) {
return roots[0]
@@ -148,9 +147,7 @@ class OnlyLatestVersionSet : MutableSet<Note> {
}
}
override fun addAll(elements: Collection<Note>): Boolean {
return elements.map { add(it) }.any()
}
override fun addAll(elements: Collection<Note>): Boolean = elements.map { add(it) }.any()
override val size: Int
get() = set.size
@@ -160,29 +157,17 @@ class OnlyLatestVersionSet : MutableSet<Note> {
map.clear()
}
override fun isEmpty(): Boolean {
return set.isEmpty()
}
override fun isEmpty(): Boolean = set.isEmpty()
override fun containsAll(elements: Collection<Note>): Boolean {
return set.containsAll(elements)
}
override fun containsAll(elements: Collection<Note>): Boolean = set.containsAll(elements)
override fun contains(element: Note): Boolean {
return set.contains(element)
}
override fun contains(element: Note): Boolean = set.contains(element)
override fun iterator(): MutableIterator<Note> {
return set.iterator()
}
override fun iterator(): MutableIterator<Note> = set.iterator()
override fun retainAll(elements: Collection<Note>): Boolean {
return set.retainAll(elements)
}
override fun retainAll(elements: Collection<Note>): Boolean = set.retainAll(elements)
override fun removeAll(elements: Collection<Note>): Boolean {
return elements.map { remove(it) }.any()
}
override fun removeAll(elements: Collection<Note>): Boolean = elements.map { remove(it) }.any()
override fun remove(element: Note): Boolean {
element.address()?.let {

View File

@@ -0,0 +1,128 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class LevelSignature(
val signature: String,
val createdAt: Long?,
val author: User?,
)
object ThreadLevelCalculator {
val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
private fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
note: Note,
eventsToConsider: Set<HexKey>,
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long,
): LevelSignature {
val replyTo = note.replyTo
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";",
createdAt = note.createdAt(),
author = note.author,
)
}
val parent =
(
replyTo
.filter {
it.idHex in eventsToConsider
} // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it]
?: replyLevelSignature(
it,
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now,
).apply { cachedSignatures.put(it, this) }
}.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (parent?.author == note.author && note.createdAt() != null) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) +
note.idHex.substring(0, 8)
} else if (note.author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies
} else if (note.author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = note.createdAt(),
author = note.author,
)
cachedSignatures[note] = mySignature
return mySignature
}
fun replyLevel(
note: Note,
cachedLevels: MutableMap<Note, Int> = mutableMapOf(),
): Int {
val replyTo = note.replyTo
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return 0
}
return replyTo.maxOf {
cachedLevels[it] ?: replyLevel(it, cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
}
}

View File

@@ -74,7 +74,6 @@ object UrlCachedPreviewer {
onReady(state)
}
},
)
.fetchUrlPreview()
).fetchUrlPreview()
}
}

View File

@@ -27,14 +27,16 @@ import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ContactListEvent
@@ -49,10 +51,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@Stable
class User(val pubkeyHex: String) {
class User(
val pubkeyHex: String,
) {
var info: UserMetadata? = null
var latestMetadata: MetadataEvent? = null
var latestMetadataRelay: String? = null
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
@@ -76,7 +81,16 @@ class User(val pubkeyHex: String) {
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun toNostrUri() = "nostr:${pubkeyNpub()}"
fun toNProfile(): String {
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent)?.writeRelays()
return Nip19Bech32.createNProfile(
pubkeyHex,
relayList?.take(3) ?: listOfNotNull(latestMetadataRelay),
)
}
fun toNostrUri() = "nostr:${toNProfile()}"
override fun toString(): String = pubkeyHex
@@ -96,17 +110,11 @@ class User(val pubkeyHex: String) {
return firstName
}
fun toBestDisplayName(): String {
return info?.bestName() ?: pubkeyDisplayHex()
}
fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex()
fun nip05(): String? {
return info?.nip05
}
fun nip05(): String? = info?.nip05
fun profilePicture(): String? {
return info?.picture
}
fun profilePicture(): String? = info?.picture
fun updateBookmark(event: BookmarkListEvent) {
if (event.id == latestBookmarkList?.id) return
@@ -132,10 +140,18 @@ class User(val pubkeyHex: String) {
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.invalidateData()
}
liveSet?.innerRelays?.invalidateData()
@@ -198,25 +214,19 @@ class User(val pubkeyHex: String) {
return amount
}
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
}
fun reportsBy(user: User): Set<Note> = reports[user] ?: emptySet()
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.count { it.key.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<HexKey>): Int = reports.count { it.key.pubkeyHex in users }
fun reportsBy(users: Set<HexKey>): List<Note> {
return reports
fun reportsBy(users: Set<HexKey>): List<Note> =
reports
.mapNotNull {
if (it.key.pubkeyHex in users) {
it.value
} else {
null
}
}
.flatten()
}
}.flatten()
@Synchronized
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
@@ -235,9 +245,7 @@ class User(val pubkeyHex: String) {
return getOrCreatePrivateChatroom(key)
}
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom {
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
}
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
fun addMessage(
room: ChatroomKey,
@@ -323,76 +331,29 @@ class User(val pubkeyHex: String) {
}
}
flowSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
fun isFollowing(user: User): Boolean {
return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.isTaggedHash(tag) ?: false
}
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
return latestContactList?.unverifiedFollowTagSet()?.map { it.lowercase() }?.toSet()?.let {
return tag.lowercase() in it
}
?: false
}
fun isFollowingGeohashCached(geoTag: String): Boolean {
return latestContactList?.verifiedFollowGeohashSet?.let {
fun isFollowingGeohash(geoTag: String): Boolean {
return latestContactList?.unverifiedFollowAddressSet()?.toSet()?.let {
return geoTag.lowercase() in it
}
?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
}
?: false
}
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
fun isFollowingCached(userHex: String): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return userHex in it
}
?: false
}
fun transientFollowCount(): Int? {
return latestContactList?.unverifiedFollowKeySet()?.size
}
suspend fun transientFollowerCount(): Int {
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<String> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowingGeohashSet(): Set<HexKey> {
return latestContactList?.verifiedFollowGeohashSet ?: emptySet()
}
fun cachedFollowingCommunitiesSet(): Set<HexKey> {
return latestContactList?.verifiedFollowCommunitySet ?: emptySet()
}
fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size
}
suspend fun cachedFollowerCount(): Int {
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
@@ -403,16 +364,13 @@ class User(val pubkeyHex: String) {
fun hasReport(
loggedIn: User,
type: ReportEvent.ReportType,
): Boolean {
return reports[loggedIn]?.firstOrNull {
): Boolean =
reports[loggedIn]?.firstOrNull {
it.event is ReportEvent &&
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
} != null
}
fun anyNameStartsWith(username: String): Boolean {
return info?.anyNameStartsWith(username) ?: false
}
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
var liveSet: UserLiveSet? = null
var flowSet: UserFlowSet? = null
@@ -473,23 +431,30 @@ class User(val pubkeyHex: String) {
}
@Stable
class UserFlowSet(u: User) {
class UserFlowSet(
u: User,
) {
// Observers line up here.
val metadata = UserBundledRefresherFlow(u)
val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean {
return relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
}
fun isInUse(): Boolean =
metadata.stateFlow.subscriptionCount.value > 0 ||
relays.stateFlow.subscriptionCount.value > 0 ||
follows.stateFlow.subscriptionCount.value > 0
fun destroy() {
metadata.destroy()
relays.destroy()
follows.destroy()
}
}
@Stable
class UserLiveSet(u: User) {
class UserLiveSet(
u: User,
) {
val innerMetadata = UserBundledRefresherLiveData(u)
// UI Observers line up here.
@@ -521,8 +486,8 @@ class UserLiveSet(u: User) {
val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged()
fun isInUse(): Boolean {
return metadata.hasObservers() ||
fun isInUse(): Boolean =
metadata.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
@@ -535,7 +500,6 @@ class UserLiveSet(u: User) {
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers()
}
fun destroy() {
innerMetadata.destroy()
@@ -558,7 +522,9 @@ data class RelayInfo(
var counter: Long,
)
class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
class UserBundledRefresherLiveData(
val user: User,
) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -585,7 +551,9 @@ class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserSta
}
@Stable
class UserBundledRefresherFlow(val user: User) {
class UserBundledRefresherFlow(
val user: User,
) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(UserState(user))
@@ -605,7 +573,10 @@ class UserBundledRefresherFlow(val user: User) {
}
}
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
class UserLoadingLiveData<Y>(
val user: User,
initialValue: Y?,
) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user)
@@ -617,4 +588,6 @@ class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveDat
}
}
@Immutable class UserState(val user: User)
@Immutable class UserState(
val user: User,
)

View File

@@ -0,0 +1,64 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
object CreatedAtComparator : Comparator<Note> {
override fun compare(
first: Note?,
second: Note?,
): Int {
val firstEvent = first?.event
val secondEvent = second?.event
return if (firstEvent == null && secondEvent == null) {
0
} else if (firstEvent == null) {
1
} else if (secondEvent == null) {
-1
} else {
firstEvent.createdAt().compareTo(secondEvent.createdAt())
}
}
}
object CreatedAtComparatorAddresses : Comparator<AddressableNote> {
override fun compare(
first: AddressableNote?,
second: AddressableNote?,
): Int {
val firstEvent = first?.event
val secondEvent = second?.event
return if (firstEvent == null && secondEvent == null) {
0
} else if (firstEvent == null) {
1
} else if (secondEvent == null) {
-1
} else {
firstEvent.createdAt().compareTo(secondEvent.createdAt())
}
}
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class LatestByKindAndAuthor<T : Event>(
private val kind: Int,
private val pubkey: String,
) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
fun matches(event: T) = event.kind == kind && event.pubKey == pubkey
fun updateIfMatches(event: T) {
if (matches(event)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
}
}
}
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
fun restart() {
val latestNote =
if ((kind in 10000..19999) || (kind in 30000..39999)) {
LocalCache.addressables
.maxOrNullOf(
filter = { idHex: String, note: AddressableNote ->
note.event?.let {
it.kind() == kind && it.pubKey() == pubkey
} == true
},
comparator = CreatedAtComparatorAddresses,
)?.event as? T
} else {
LocalCache.notes
.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind() == kind && it.pubKey() == pubkey
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
}
if (_latest.value != latestNote) {
_latest.tryEmit(latestNote)
}
}
suspend fun init() {
restart()
}
}

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class LatestByKindWithETag<T : Event>(
private val kind: Int,
private val eTag: String,
) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
fun matches(event: T) = event.kind == kind && event.isTaggedEvent(eTag)
fun updateIfMatches(event: T) {
if (matches(event)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
}
}
}
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
fun restart() {
val latestNote =
LocalCache.notes
.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind() == kind && it.isTaggedEvent(eTag)
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
if (_latest.value != latestNote) {
_latest.tryEmit(latestNote)
}
}
suspend fun init() {
restart()
}
}

View File

@@ -0,0 +1,51 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.ammolite.relays.NostrDataSource
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.Event
abstract class AmethystNostrDataSource(
debugName: String,
) : NostrDataSource(debugName) {
override fun consume(
event: Event,
relay: Relay,
) {
LocalCache.verifyAndConsume(event, relay)
}
override fun markAsSeenOnRelay(
eventId: String,
relay: Relay,
) {
val note = LocalCache.getNoteIfExists(eventId)
val noteEvent = note?.event
if (noteEvent is AddressableEvent) {
LocalCache.getAddressableNoteIfExists(noteEvent.address().toTag())?.addRelay(relay)
} else {
note?.addRelay(relay)
}
}
}

View File

@@ -18,44 +18,47 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.robohash.Robohash
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import okio.buffer
import okio.source
import java.nio.charset.Charset
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
import java.util.Base64
@Stable
class HashImageFetcher(
private val context: Context,
private val isLightTheme: Boolean,
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): SourceResult {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val source =
try {
Robohash.assemble(data.toString(), isLightTheme).byteInputStream(Charset.defaultCharset()).source().buffer()
} finally {
}
val matcher = base64contentPattern.matcher(data.toString())
return SourceResult(
source = ImageSource(source, context),
mimeType = "image/svg+xml",
dataSource = DataSource.MEMORY,
)
if (matcher.find()) {
val base64String = matcher.group(2)
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) ?: throw Exception("Unable to load base64 $base64String")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY,
)
} else {
throw Exception("Unable to load base64 $data")
}
}
object Factory : Fetcher.Factory<Uri> {
@@ -63,28 +66,24 @@ class HashImageFetcher(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher {
return HashImageFetcher(
options.context,
options.parameters.value("isLightTheme") ?: true,
data,
)
): Fetcher? {
return if (base64contentPattern.matcher(data.toString()).find()) {
return Base64Fetcher(options, data)
} else {
null
}
}
}
}
@Deprecated("Use the RobohashAssembler instead")
object RobohashImageRequest {
fun build(
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
isLightTheme: Boolean,
): ImageRequest {
return ImageRequest.Builder(context)
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(HashImageFetcher.Factory)
.setParameter("isLightTheme", isLightTheme)
.addHeader("Cache-Control", "max-age=31536000")
.fetcherFactory(Base64Fetcher.Factory)
.build()
}
}

View File

@@ -31,9 +31,9 @@ import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
@Stable
class BlurHashFetcher(
@@ -43,23 +43,9 @@ class BlurHashFetcher(
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "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")
}
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
@@ -73,9 +59,7 @@ class BlurHashFetcher(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher {
return BlurHashFetcher(options, data)
}
): Fetcher = BlurHashFetcher(options, data)
}
}
@@ -86,7 +70,8 @@ object BlurHashRequester {
): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest.Builder(context)
return ImageRequest
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.build()

View File

@@ -26,17 +26,33 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.quartz.events.ImmutableListOfLists
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(50)
private val richTextCache = LruCache<Int, RichTextViewerState>(50)
private fun hashCodeCache(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String?,
): Int {
var result = content.hashCode()
result = 31 * result + tags.lists.hashCode()
if (callbackUri != null) {
result = 31 * result + callbackUri.hashCode()
}
return result
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState {
return if (richTextCache[content] != null) {
richTextCache[content]
val key = hashCodeCache(content, tags, callbackUri)
val cached = richTextCache[key]
return if (cached != null) {
cached
} else {
val newUrls = RichTextParser().parseText(content, tags)
richTextCache.put(content, newUrls)
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
richTextCache.put(key, newUrls)
newUrls
}
}

View File

@@ -0,0 +1,411 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Event
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
@Immutable
data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: List<Proof>,
)
@Serializable
@Immutable
class Proof(
val amount: Int,
val id: String,
val secret: String,
val C: String,
)
object CachedCashuProcessor {
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
checkNotInMainThread()
val newCachuData = CashuProcessor().parse(token)
cashuCache.put(token, newCachuData)
}
return cashuCache[token]
}
}
class CashuProcessor {
@Serializable
class V3Token(
val unit: String?, // unit
val memo: String?, // memo
val token: List<V3T>?,
)
@Serializable
class V3T(
val mint: String,
val proofs: List<Proof>,
)
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
if (cashuToken.startsWith("cashuA")) {
return parseCashuA(cashuToken)
}
if (cashuToken.startsWith("cashuB")) {
return parseCashuB(cashuToken)
}
return GenericLoadable.Error("Could not parse this cashu token")
}
fun parseCashuA(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
if (cashu.token == null) {
return GenericLoadable.Error("No token found")
}
val converted =
cashu.token.map { token ->
val proofs = token.proofs
val mint = token.mint
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
@Serializable
class V4Token(
val m: String, // mint
val u: String, // unit
val d: String? = null, // memo
val t: Array<V4T>?,
)
@Serializable
class V4T(
@ByteString
val i: ByteArray, // identifier
val p: Array<V4Proof>,
)
@Serializable
class V4Proof(
val a: Int, // amount
val s: String, // secret
@ByteString
val c: ByteArray, // signature
val d: V4DleqProof? = null, // no idea what this is
val w: String? = null, // witness
)
@Serializable
class V4DleqProof(
@ByteString
val e: ByteArray,
@ByteString
val s: ByteArray,
@ByteString
val r: ByteArray,
)
@OptIn(ExperimentalSerializationApi::class)
fun parseCashuB(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuB", "")
val parser = Cbor { ignoreUnknownKeys = true }
val v4Token = parser.decodeFromByteArray<V4Token>(Base64.getUrlDecoder().decode(base64token))
val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found")
val converted =
v4proofs.map { id ->
val proofs =
id.p.map {
Proof(
it.a,
id.i.toHexKey(),
it.s,
it.c.toHexKey(),
)
}
val mint = v4Token.m
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
e.printStackTrace()
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
suspend fun melt(
token: CashuToken,
lud16: String,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
runCatching {
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
onSuccess = { baseInvoice ->
feeCalculator(
token.mint,
baseInvoice,
onSuccess = { fees ->
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, fees, onSuccess, onError, context)
},
onProgress = {},
onError = onError,
context = context,
)
},
onError = onError,
context,
)
},
onProgress = {},
onError = onError,
context = context,
)
}
}
fun feeCalculator(
mintAddress: String,
invoice: String,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
try {
val client = HttpClientManager.getHttpClient()
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost != null) {
onSuccess(
feeCost,
)
} else {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
onError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
private fun meltInvoice(
token: CashuToken,
invoice: String,
fees: Int,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
try {
val client = HttpClientManager.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.replace(
"proofs",
factory.arrayNode(token.proofs.size).apply {
token.proofs.forEach {
addObject().apply {
put("amount", it.amount)
put("id", it.id)
put("secret", it.secret)
put("C", it.C)
}
}
},
)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (successful) {
onSuccess(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(
context,
R.string.cashu_successful_redemption_explainer,
token.totalAmount.toString(),
fees.toString(),
),
)
} else {
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
onError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
}

View File

@@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.quartz.events.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean {
return Character.charCount(this.codePointAt(pos)) == 2
}
fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2
fun String.firstFullCharOld(): String {
return when (this.length) {

View File

@@ -49,7 +49,7 @@ class FileHeader(
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: (String?) -> Unit,
onError: (String) -> Unit,
) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
@@ -57,12 +57,12 @@ class FileHeader(
if (imageData != null) {
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
} else {
onError(null)
onError("Unable to download image from $fileUrl")
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError(e.message)
onError(e.message ?: e.javaClass.simpleName)
}
}
@@ -71,7 +71,7 @@ class FileHeader(
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: (String?) -> Unit,
onError: (String) -> Unit,
) {
try {
val hash = CryptoUtils.sha256(data).toHexKey()
@@ -178,7 +178,7 @@ class FileHeader(
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError(e.message)
onError(e.message ?: e.javaClass.simpleName)
}
}
}
@@ -236,10 +236,10 @@ fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
}
}
class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
override fun getSize(): Long {
return imageData.size.toLong()
}
class ByteArrayMediaDataSource(
var imageData: ByteArray,
) : MediaDataSource() {
override fun getSize(): Long = imageData.size.toLong()
@Throws(IOException::class)
override fun readAt(

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.R
class HttpStatusMessages {
companion object {
fun resourceIdFor(statusCode: Int = 0): Int? =
when (statusCode) {
400 -> R.string.http_status_400
401 -> R.string.http_status_401
402 -> R.string.http_status_402
403 -> R.string.http_status_403
404 -> R.string.http_status_404
405 -> R.string.http_status_405
406 -> R.string.http_status_406
407 -> R.string.http_status_407
408 -> R.string.http_status_408
409 -> R.string.http_status_409
410 -> R.string.http_status_410
411 -> R.string.http_status_411
412 -> R.string.http_status_412
413 -> R.string.http_status_413
414 -> R.string.http_status_414
415 -> R.string.http_status_415
416 -> R.string.http_status_416
417 -> R.string.http_status_417
426 -> R.string.http_status_426
500 -> R.string.http_status_500
501 -> R.string.http_status_501
502 -> R.string.http_status_502
503 -> R.string.http_status_503
504 -> R.string.http_status_504
505 -> R.string.http_status_505
506 -> R.string.http_status_506
507 -> R.string.http_status_507
508 -> R.string.http_status_508
511 -> R.string.http_status_511
else -> null
}
}
}

View File

@@ -33,7 +33,9 @@ import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
class LocationUtil(context: Context) {
class LocationUtil(
context: Context,
) {
companion object {
const val MIN_TIME: Long = 1000L
const val MIN_DISTANCE: Float = 0.0f
@@ -96,9 +98,7 @@ class LocationUtil(context: Context) {
object CachedGeoLocations {
val locationNames = LruCache<String, String>(20)
fun cached(geoHashStr: String): String? {
return locationNames[geoHashStr]
}
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
suspend fun geoLocate(
geoHashStr: String,

View File

@@ -31,4 +31,6 @@ fun checkNotInMainThread() {
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
class OnMainThreadException(str: String) : RuntimeException(str)
class OnMainThreadException(
str: String,
) : RuntimeException(str)

View File

@@ -22,15 +22,13 @@ package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
class Nip05NostrAddressVerifier() {
class Nip05NostrAddressVerifier {
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
@@ -46,7 +44,7 @@ class Nip05NostrAddressVerifier() {
suspend fun fetchNip05Json(
nip05: String,
onSuccess: (String) -> Unit,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) = withContext(Dispatchers.IO) {
checkNotInMainThread()
@@ -60,52 +58,39 @@ class Nip05NostrAddressVerifier() {
try {
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClientManager.getHttpClient()
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
HttpClientManager
.getHttpClient()
.newBuilder()
.followRedirects(false)
.build()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(
call: Call,
response: Response,
) {
checkNotInMainThread()
.execute()
.use {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct",
)
}
}
}
override fun onFailure(
call: Call,
e: java.io.IOException,
) {
onError(
"Could not resolve $url. Check if the server is up and if the address $nip05 is correct",
)
e.printStackTrace()
}
},
)
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct",
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Could not resolve '$url': ${e.message}")
onError("Could not resolve NIP-05 $nip05 as URL $url: ${e.message}")
}
}
suspend fun verifyNip05(
nip05: String,
onSuccess: (String) -> Unit,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) {
// check fails on tests

View File

@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import okhttp3.Call
@@ -32,17 +34,26 @@ import okhttp3.Response
import java.io.IOException
object Nip11CachedRetriever {
open class RetrieveResult(val time: Long)
open class RetrieveResult(
val time: Long,
)
class RetrieveResultError(val error: Nip11Retriever.ErrorCode, val msg: String? = null) : RetrieveResult(TimeUtils.now())
class RetrieveResultError(
val error: Nip11Retriever.ErrorCode,
val msg: String? = null,
) : RetrieveResult(TimeUtils.now())
class RetrieveResultSuccess(val data: Nip11RelayInformation) : RetrieveResult(TimeUtils.now())
class RetrieveResultSuccess(
val data: Nip11RelayInformation,
) : RetrieveResult(TimeUtils.now())
val relayInformationDocumentCache = LruCache<String, RetrieveResult?>(100)
val retriever = Nip11Retriever()
class RetrieveResultLoading : RetrieveResult(TimeUtils.now())
private val relayInformationDocumentCache = LruCache<String, RetrieveResult?>(100)
private val retriever = Nip11Retriever()
fun getFromCache(dirtyUrl: String): Nip11RelayInformation? {
val result = relayInformationDocumentCache.get(retriever.cleanUrl(dirtyUrl)) ?: return null
val result = relayInformationDocumentCache.get(RelayUrlFormatter.getHttpsUrl(dirtyUrl)) ?: return null
if (result is RetrieveResultSuccess) return result.data
return null
}
@@ -52,47 +63,53 @@ object Nip11CachedRetriever {
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
val url = retriever.cleanUrl(dirtyUrl)
checkNotInMainThread()
val url = RelayUrlFormatter.getHttpsUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
if (doc != null) {
if (doc is RetrieveResultSuccess) {
onInfo(doc.data)
} else if (doc is RetrieveResultLoading) {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) {
// just wait.
} else {
retrieve(url, dirtyUrl, onInfo, onError)
}
} else if (doc is RetrieveResultError) {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
onError(dirtyUrl, doc.error, null)
} else {
Nip11Retriever()
.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
retrieve(url, dirtyUrl, onInfo, onError)
}
}
} else {
Nip11Retriever()
.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
retrieve(url, dirtyUrl, onInfo, onError)
}
}
private suspend fun retrieve(
url: String,
dirtyUrl: String,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
relayInformationDocumentCache.put(url, RetrieveResultLoading())
retriever.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
checkNotInMainThread()
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
checkNotInMainThread()
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
}
}
class Nip11Retriever {
@@ -103,14 +120,6 @@ class Nip11Retriever {
FAIL_WITH_HTTP_STATUS,
}
fun cleanUrl(dirtyUrl: String): String {
return if (dirtyUrl.contains("://")) {
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
}
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
@@ -120,10 +129,14 @@ class Nip11Retriever {
checkNotInMainThread()
try {
val request: Request =
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
val isLocalHost = dirtyUrl.startsWith("ws://127.0.0.1") || dirtyUrl.startsWith("ws://localhost")
Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
HttpClientManager.getHttpClient(useProxy = !isLocalHost)
HttpClientManager
.getHttpClientForUrl(dirtyUrl)
.newCall(request)
.enqueue(
object : Callback {

View File

@@ -24,8 +24,11 @@ import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import okhttp3.Request
import java.net.URI
import java.net.URL
object Nip96MediaServers {
val DEFAULT =
@@ -37,7 +40,10 @@ object Nip96MediaServers {
ServerName("Void.cat", "https://void.cat"),
)
data class ServerName(val name: String, val baseUrl: String)
data class ServerName(
val name: String,
val baseUrl: String,
)
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
@@ -72,17 +78,34 @@ class Nip96Retriever {
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
)
fun parse(body: String): ServerInfo {
fun parse(
baseUrl: String,
body: String,
): ServerInfo {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, ServerInfo::class.java)
val serverInfo = mapper.readValue(body, ServerInfo::class.java)
return serverInfo.copy(
apiUrl = makeAbsoluteIfRelativeUrl(baseUrl, serverInfo.apiUrl),
downloadUrl = serverInfo.downloadUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
delegatedToUrl = serverInfo.delegatedToUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
tosUrl = serverInfo.tosUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
plans =
serverInfo.plans.mapValues { u ->
u.value.copy(
url = u.value.url?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
)
},
)
}
suspend fun loadInfo(baseUrl: String): ServerInfo {
checkNotInMainThread()
val request: Request =
Request.Builder()
Request
.Builder()
.header("Accept", "application/nostr+json")
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build()
@@ -93,7 +116,7 @@ class Nip96Retriever {
val body = it.body.string()
try {
if (it.isSuccessful) {
return parse(body)
return parse(baseUrl, body)
} else {
throw RuntimeException(
"Resulting Message from $baseUrl is an error: ${response.code} ${response.message}",
@@ -112,3 +135,18 @@ class Nip96Retriever {
typealias PlanName = String
typealias MimeType = String
fun makeAbsoluteIfRelativeUrl(
baseUrl: String,
potentialyRelativeUrl: String,
): String =
try {
val apiUrl = URI(potentialyRelativeUrl)
if (apiUrl.isAbsolute) {
potentialyRelativeUrl
} else {
URL(URL(baseUrl), potentialyRelativeUrl).toString()
}
} catch (e: Exception) {
potentialyRelativeUrl
}

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
@@ -29,7 +30,10 @@ import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
@@ -47,7 +51,9 @@ val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
class Nip96Uploader(val account: Account?) {
class Nip96Uploader(
val account: Account?,
) {
suspend fun uploadImage(
uri: Uri,
contentType: String?,
@@ -57,6 +63,7 @@ class Nip96Uploader(val account: Account?) {
server: Nip96MediaServers.ServerName,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit,
context: Context,
): PartialEvent {
val serverInfo =
Nip96Retriever()
@@ -73,6 +80,7 @@ class Nip96Uploader(val account: Account?) {
serverInfo,
contentResolver,
onProgress,
context,
)
}
@@ -85,6 +93,7 @@ class Nip96Uploader(val account: Account?) {
server: Nip96Retriever.ServerInfo,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit,
context: Context,
): PartialEvent {
checkNotInMainThread()
@@ -110,6 +119,7 @@ class Nip96Uploader(val account: Account?) {
sensitiveContent,
server,
onProgress,
context,
)
}
@@ -121,6 +131,7 @@ class Nip96Uploader(val account: Account?) {
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit,
context: Context,
): PartialEvent {
checkNotInMainThread()
@@ -129,11 +140,11 @@ class Nip96Uploader(val account: Account?) {
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClientManager.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
requestBody =
MultipartBody.Builder()
val requestBody: RequestBody =
MultipartBody
.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("expiration", "")
.addFormDataPart("size", length.toString())
@@ -141,8 +152,7 @@ class Nip96Uploader(val account: Account?) {
alt?.let { body.addFormDataPart("alt", it) }
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
contentType?.let { body.addFormDataPart("content_type", it) }
}
.addFormDataPart(
}.addFormDataPart(
"file",
"$fileName.$extension",
object : RequestBody() {
@@ -154,8 +164,7 @@ class Nip96Uploader(val account: Account?) {
inputStream.source().use(sink::writeAll)
}
},
)
.build()
).build()
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
@@ -177,11 +186,34 @@ class Nip96Uploader(val account: Account?) {
} else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event
} else {
throw RuntimeException("Failed to upload with message: ${result.message}")
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message))
}
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
val msg = response.body.string()
val errorMessage =
try {
val tree = jacksonObjectMapper().readTree(msg)
val status = tree.get("status")?.asText()
val message = tree.get("message")?.asText()
if (status == "error" && message != null) {
message
} else {
null
}
} catch (e: Exception) {
null
}
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (errorMessage != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage))
} else if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code))
}
}
}
}
@@ -190,6 +222,7 @@ class Nip96Uploader(val account: Account?) {
hash: String,
contentType: String?,
server: Nip96Retriever.ServerInfo,
context: Context,
): Boolean {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
@@ -215,7 +248,12 @@ class Nip96Uploader(val account: Account?) {
return result.status == "success"
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code))
}
}
}
}
@@ -232,7 +270,8 @@ class Nip96Uploader(val account: Account?) {
onProgress((currentResult.percentage ?: 100) / 100f)
val request: Request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(result.processingUrl)
.build()
@@ -256,13 +295,12 @@ class Nip96Uploader(val account: Account?) {
}
}
suspend fun nip98Header(url: String): String? {
return withTimeoutOrNull(5000) {
suspend fun nip98Header(url: String): String? =
withTimeoutOrNull(5000) {
suspendCancellableCoroutine { continuation ->
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
}
}
}
fun nip98Header(
url: String,

View File

@@ -23,13 +23,14 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
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.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
@@ -39,11 +40,13 @@ import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
@@ -56,130 +59,152 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.filter
// TODO: Migrate this to a property of AccountVi
object NostrAccountDataSource : NostrDataSource("AccountData") {
object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
lateinit var account: Account
var otherAccounts = listOf<HexKey>()
val latestEOSEs = EOSEAccount()
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
fun createAccountContactListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
),
)
}
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
fun createAccountMetadataFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
),
)
}
fun createAccountRelayListFilter(): TypedFilter {
return TypedFilter(
fun createAccountRelayListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND),
SincePerRelayFilter(
kinds =
listOf(
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5,
limit = 10,
),
)
}
fun createOtherAccountsBaseFilter(): TypedFilter? {
if (otherAccounts.isEmpty()) return null
val otherAuthors = otherAccounts.filter { it != account.userProfile().pubkeyHex }
if (otherAuthors.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
authors = otherAccounts.filter { it != account.userProfile().pubkeyHex },
authors = otherAuthors,
limit = 100,
),
)
}
fun createAccountSettingsFilter(): TypedFilter {
return TypedFilter(
fun createAccountSettingsFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
),
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
fun createAccountReportsFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList,
),
)
}
fun createAccountLastPostsListFilter(): TypedFilter {
return TypedFilter(
fun createAccountLastPostsListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400,
),
)
}
fun createNotificationFilter(): TypedFilter {
val since =
var since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?.toMutableMap()
if (since == null) {
since =
account.connectToRelays.value
.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
.toMutableMap()
} else {
account.connectToRelays.value.forEach {
val eose = since.get(it.url)
if (eose == null) {
since.put(it.url, EOSETime(TimeUtils.oneWeekAgo()))
}
}
}
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -204,15 +229,15 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
val since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(
GitReplyEvent.KIND,
@@ -234,9 +259,17 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get("&&((GIFTWRAPS_EOSE))&&")
?.relayList
?.mapValues {
EOSETime(it.value.time - TimeUtils.twoDays())
},
),
)
@@ -245,7 +278,14 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
if (hasLoadedTheBasics[account.userProfile()] != null) {
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultNotificationFollowList.value,
account.settings.defaultNotificationFollowList.value,
relayUrl,
time,
)
latestEOSEs.addOrUpdate(
account.userProfile(),
"&&((GIFTWRAPS_EOSE))&&",
relayUrl,
time,
)
@@ -259,51 +299,108 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
override fun consume(
event: Event,
relay: Relay,
) {
if (LocalCache.justVerify(event)) {
consumeAlreadyVerified(event, relay)
}
}
fun consumeAlreadyVerified(
event: Event,
relay: Relay,
) {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
when (event) {
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
when (event) {
is PrivateOutboxRelayListEvent -> {
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
event.privateTags(account.signer) {
LocalCache.justConsume(event, relay)
}
}
}
if (!event.isDeleted()) {
val note = LocalCache.getNoteIfExists(event.id)
if (note?.event != null && relay.brief in note.relays) return
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
if (!event.isDeleted()) {
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event
if (noteEvent != null) {
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
LocalCache.consume(event, relay)
}
} else {
// decrypts
event.cachedDraft(account.signer) {}
LocalCache.justConsume(event, relay)
}
}
}
is GiftWrapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note?.event != null && relay.brief in note.relays) return
is GiftWrapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
val noteEvent = note?.event as? GiftWrapEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
LocalCache.justConsume(noteEvent, relay)
event.cachedGift(account.signer) { this.consume(it, relay) }
LocalCache.justConsume(event, relay)
noteEvent.innerEventId?.let {
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
this.consumeAlreadyVerified(it, relay)
}
} ?: run {
event.unwrap(account.signer) {
this.consume(it, relay)
noteEvent.innerEventId = it.id
}
}
}
} else {
// new event
event.unwrap(account.signer) {
LocalCache.justConsume(event, relay)
this.consume(it, relay)
}
}
}
is SealedGossipEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note?.event != null && relay.brief in note.relays) return
is SealedGossipEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
val noteEvent = note?.event as? SealedGossipEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
LocalCache.justConsume(noteEvent, relay)
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
LocalCache.justConsume(event, relay)
noteEvent.innerEventId?.let {
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
LocalCache.justConsume(it, relay)
}
} ?: run {
event.unseal(account.signer) {
LocalCache.justConsume(it, relay)
noteEvent.innerEventId = it.id
}
}
}
} else {
// new event
event.unseal(account.signer) {
LocalCache.justConsume(event, relay)
LocalCache.justConsume(it, relay)
}
}
}
is LnZapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note?.event != null && relay.brief in note.relays) return
is LnZapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note?.event == null) {
event.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
@@ -312,10 +409,10 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
LocalCache.justConsume(event, relay)
}
}
else -> {
LocalCache.justConsume(event, relay)
}
else -> {
LocalCache.justConsume(event, relay)
}
}
}
@@ -329,30 +426,41 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
super.markAsSeenOnRelay(eventId, relay)
val note = LocalCache.getNoteIfExists(eventId) ?: return
val privKey = account.keyPair.privKey ?: return
val noteEvent = note.event ?: return
markInnerAsSeenOnRelay(noteEvent, privKey, relay)
markInnerAsSeenOnRelay(noteEvent, relay)
}
private fun markInnerAsSeenOnRelay(
noteEvent: EventInterface,
privKey: ByteArray,
newNoteEvent: EventInterface,
relay: Relay,
) {
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
markInnerAsSeenOnRelay(newNoteEvent.id(), relay)
}
if (noteEvent is GiftWrapEvent) {
noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, privKey, relay) }
} else if (noteEvent is SealedGossipEvent) {
noteEvent.cachedGossip(account.signer) { rumor ->
markInnerAsSeenOnRelay(rumor, privKey, relay)
private fun markInnerAsSeenOnRelay(
eventId: HexKey,
relay: Relay,
) {
val note = LocalCache.getNoteIfExists(eventId)
if (note != null) {
note.addRelay(relay)
val noteEvent = note.event
if (noteEvent is GiftWrapEvent) {
noteEvent.innerEventId?.let {
markInnerAsSeenOnRelay(it, relay)
}
} else if (noteEvent is SealedGossipEvent) {
noteEvent.innerEventId?.let {
markInnerAsSeenOnRelay(it, relay)
}
}
}
}
override fun updateChannelFilters() {
return if (hasLoadedTheBasics[account.userProfile()] != null) {
override fun updateChannelFilters() =
if (hasLoadedTheBasics[account.userProfile()] != null) {
// gets everything about the user logged in
accountChannel.typedFilters =
listOfNotNull(
@@ -366,8 +474,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountSettingsFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
} else {
// just the basics.
accountChannel.typedFilters =
@@ -376,10 +483,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountSettingsFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
}
}
override fun auth(
relay: Relay,

View File

@@ -24,13 +24,13 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
var account: Account? = null
var channel: Channel? = null
@@ -58,7 +58,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -71,7 +71,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -86,7 +86,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200,
@@ -96,7 +96,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200,
@@ -113,7 +113,6 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
listOfNotNull(
createMessagesToChannelFilter(),
createMessagesByMeToChannelFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
}
}

View File

@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.PrivateDmEvent
object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
lateinit var account: Account
private var withRoom: ChatroomKey? = null
@@ -50,9 +50,9 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = myPeer.users.map { it },
authors = myPeer.users.toList(),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]
@@ -73,7 +73,7 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to myPeer.users.map { it }),
@@ -103,7 +103,6 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
}
}

View File

@@ -21,17 +21,17 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
lateinit var account: Account
val latestEOSEs = EOSEAccount()
@@ -41,11 +41,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
latestEOSEs.users[account.userProfile()]
?.followList
?.get(chatRoomList)
?.relayList,
),
)
@@ -53,11 +56,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
latestEOSEs.users[account.userProfile()]
?.followList
?.get(chatRoomList)
?.relayList,
),
)
@@ -65,11 +71,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
latestEOSEs.users[account.userProfile()]
?.followList
?.get(chatRoomList)
?.relayList,
),
)
@@ -80,13 +89,16 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
return TypedFilter(
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingEvents.toList(),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
latestEOSEs.users[account.userProfile()]
?.followList
?.get(chatRoomList)
?.relayList,
),
)
}
@@ -99,9 +111,9 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
return followingEvents.map {
TypedFilter(
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it)),
limit = 1,
@@ -119,11 +131,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOf(it)),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
latestEOSEs.users[account.userProfile()]
?.followList
?.get(chatRoomList)
?.relayList,
// Remember to consider spam that is being removed from the UI
limit = 50,
),
@@ -149,8 +164,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter(),
)
.flatten()
).flatten()
.ifEmpty { null }
}
}

View File

@@ -21,13 +21,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed") {
private var communityToWatch: AddressableNote? = null
private fun createLoadCommunityFilter(): TypedFilter? {
@@ -35,15 +35,19 @@ object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
val authors =
community
.moderators()
.map { it.key }
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex))
if (authors.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
authors =
community
.moderators()
.map { it.key }
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
SincePerRelayFilter(
authors = authors,
tags =
mapOf(
"a" to listOf(myCommunityToWatch.address.toTag()),

View File

@@ -23,9 +23,11 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -38,7 +40,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
@@ -65,22 +67,30 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
?.ifEmpty { null }
val geohashToLoad =
account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
?.ifEmpty { null }
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
@@ -88,7 +98,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
@@ -101,7 +111,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -110,7 +120,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
@@ -123,7 +133,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -131,21 +141,45 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
)
}
fun createNIP89Filter(kTags: List<String>): List<TypedFilter> =
listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 300,
tags = mapOf("k" to kTags),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
)
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
?.ifEmpty { null }
val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
authors = follows,
SinceAuthorPerRelayFilter(
authors = followsRelays,
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
@@ -153,14 +187,14 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
SincePerRelayFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -169,21 +203,21 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}
fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
val followChats = account.selectedChatsFollowList().toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ChannelMessageEvent.KIND),
limit = 500,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
@@ -191,14 +225,14 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
SincePerRelayFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -209,33 +243,36 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createLiveStreamTagsFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
@@ -248,21 +285,24 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createLiveStreamGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
@@ -275,21 +315,24 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
@@ -303,21 +346,24 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createPublicChatsGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
@@ -331,21 +377,24 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
@@ -358,21 +407,24 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createCommunitiesGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
@@ -385,7 +437,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -395,7 +447,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultDiscoveryFollowList.value,
account.settings.defaultDiscoveryFollowList.value,
relayUrl,
time,
)
@@ -404,6 +456,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters =
createLiveStreamFilter()
.plus(createNIP89Filter(listOf("5300")))
.plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
.plus(
@@ -416,7 +469,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
createPublicChatsTagsFilter(),
createPublicChatsGeohashesFilter(),
),
)
).toList()
.ifEmpty { null }
}
}

View File

@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -32,8 +32,9 @@ import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") {
private var geohashToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
@@ -42,7 +43,7 @@ object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
tags =
mapOf(
"g" to
@@ -61,6 +62,7 @@ object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
),
limit = 200,
),

View File

@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -32,8 +32,9 @@ import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
@@ -42,7 +43,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
tags =
mapOf(
"t" to
@@ -64,6 +65,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
),
limit = 200,
),

View File

@@ -23,9 +23,11 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
@@ -35,51 +37,62 @@ import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount()
var job: Job? = null
var job2: Job? = null
override fun start() {
job?.cancel()
job =
scope.launch(Dispatchers.IO) {
// creates cache on main
withContext(Dispatchers.Main) { account.userProfile().live() }
account.liveHomeFollowLists.collect {
if (this@NostrHomeDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
job2?.cancel()
job2 =
scope.launch(Dispatchers.IO) {
account.liveHomeListAuthorsPerRelay.collect {
if (this@NostrHomeDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
super.start()
}
override fun stop() {
super.stop()
job?.cancel()
job2?.cancel()
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
val follows =
account.liveHomeListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
JsonFilter(
SinceAuthorPerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -94,18 +107,45 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
PinListEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
),
authors = followSet,
authors = follows,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
}
fun createFollowMetadataAndReleaseFilter(): TypedFilter? {
val follows = account.liveHomeListAuthorsPerRelay.value
return if (!follows.isNullOrEmpty()) {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
),
authors = follows,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
} else {
null
}
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
@@ -114,16 +154,19 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
),
tags =
mapOf(
@@ -136,7 +179,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -150,16 +193,19 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
),
tags =
mapOf(
@@ -172,7 +218,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -186,7 +232,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -196,6 +242,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
),
tags =
@@ -206,7 +253,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -216,7 +263,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultHomeFollowList.value,
account.settings.defaultHomeFollowList.value,
relayUrl,
time,
)
@@ -226,10 +273,10 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
followAccountChannel.typedFilters =
listOfNotNull(
createFollowAccountsFilter(),
createFollowMetadataAndReleaseFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
}
}

View File

@@ -20,13 +20,10 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.signers.NostrSigner
class NostrLnZapPaymentResponseDataSource(
@@ -34,7 +31,7 @@ class NostrLnZapPaymentResponseDataSource(
private val toUserHex: String,
private val replyingToHex: String,
private val authSigner: NostrSigner,
) : NostrDataSource("LnZapPaymentResponseFeed") {
) : AmethystNostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT)
private fun createWalletConnectServiceWatcher(): TypedFilter {
@@ -42,7 +39,7 @@ class NostrLnZapPaymentResponseDataSource(
return TypedFilter(
types = feedTypes,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(fromServiceHex),
tags =
@@ -62,18 +59,4 @@ class NostrLnZapPaymentResponseDataSource(
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
}
override fun auth(
relay: Relay,
challenge: String,
) {
super.auth(relay, challenge)
RelayAuthEvent.create(relay.url, challenge, authSigner) {
Client.send(
it,
relay.url,
)
}
}
}

View File

@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
@@ -49,9 +49,10 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlin.coroutines.cancellation.CancellationException
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFeed") {
private var searchString: String? = null
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
@@ -94,7 +95,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, aTag.kind),
authors = listOfNotNull(aTag.pubKeyHex),
// just to be sure
@@ -109,7 +110,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
ids = listOfNotNull(hexToWatch),
),
),
@@ -117,7 +118,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
// just to be sure
@@ -134,16 +135,16 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 100,
limit = 1000,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -164,7 +165,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
@@ -175,6 +176,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
WikiNoteEvent.KIND,
),
search = mySearchString,
limit = 100,

View File

@@ -23,14 +23,14 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<Channel>()
private fun createMetadataChangeFilter(): TypedFilter? {
@@ -44,7 +44,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to reactionsToWatch),
),
@@ -63,9 +63,9 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
// downloads linked events to this event.
return TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = interestedEvents.toList(),
),
@@ -86,9 +86,9 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),

View File

@@ -0,0 +1,371 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NIP90StatusEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
private var nextEventsToWatch = setOf<Note>()
private var nextAddressesToWatch = setOf<Note>()
private var eventsToWatchInProd = setOf<Note>()
private var addressesToWatchInProd = setOf<Note>()
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
val myAddressesToWatch =
(
eventsToWatchInProd.filter { it.address() != null } +
addressesToWatchInProd.filter { it.address() != null }
).toSet()
if (myAddressesToWatch.isEmpty()) {
return null
}
return groupByEOSEPresence(myAddressesToWatch)
.map {
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
),
),
)
}.flatten()
}
private fun createAddressFilter(): List<TypedFilter>? {
val myAddressesToWatch = addressesToWatchInProd.filter { it.event == null }
if (myAddressesToWatch.isEmpty()) {
return null
}
return myAddressesToWatch.mapNotNull {
it.address()?.let { aTag ->
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
} else {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
}
}
}
}
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
if (eventsToWatchInProd.isEmpty()) {
return null
}
return groupByEOSEPresence(eventsToWatchInProd)
.map {
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
GitReplyEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10000,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
limit = 100,
),
),
)
}.flatten()
}
private fun createQuotesFilter(): List<TypedFilter>? {
if (eventsToWatchInProd.isEmpty()) {
return null
}
return groupByEOSEPresence(eventsToWatchInProd)
.map {
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(TextNoteEvent.KIND),
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
)
}.flatten()
}
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatchInProd.filter { it.event == null }
val threadingEventsToLoad =
eventsToWatchInProd
.mapNotNull { it.replyTo }
.flatten()
.filter { it !is AddressableNote && it.event == null }
val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
ids = interestedEvents.toList(),
),
),
)
}
val singleEventChannel =
requestNewChannel { time, relayUrl ->
// Ignores EOSE if it is in the middle of a filter change.
if (changingFilters.get()) return@requestNewChannel
checkNotInMainThread()
eventsToWatchInProd.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
addressesToWatchInProd.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
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()
}
override fun updateChannelFilters() {
addressesToWatchInProd = nextAddressesToWatch
eventsToWatchInProd = nextEventsToWatch
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
val addressReactions = createReactionsToWatchInAddressFilter()
val quotes = createQuotesFilter()
singleEventChannel.typedFilters =
listOfNotNull(missing, addresses, reactions, addressReactions, quotes).flatten().ifEmpty { null }
}
fun add(eventId: Note) {
if (!nextEventsToWatch.contains(eventId)) {
nextEventsToWatch = nextEventsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: Note) {
if (nextEventsToWatch.contains(eventId)) {
nextEventsToWatch = nextEventsToWatch.minus(eventId)
invalidateFilters()
}
}
fun addAddress(addressableNote: Note) {
if (!nextAddressesToWatch.contains(addressableNote)) {
nextAddressesToWatch = nextAddressesToWatch.plus(addressableNote)
invalidateFilters()
}
}
fun removeAddress(addressableNote: Note) {
if (nextAddressesToWatch.contains(addressableNote)) {
nextAddressesToWatch = nextAddressesToWatch.minus(addressableNote)
invalidateFilters()
}
}
}
fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> =
notes
.groupBy {
it.lastReactionsDownloadTime.keys
.sorted()
.joinToString(",")
}.values
.map {
it.sortedBy { it.idHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
}
fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> =
users
.groupBy {
it.latestEOSEs.keys
.sorted()
.joinToString(",")
}.values
.map {
it.sortedBy { it.pubkeyHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
}
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
notes.forEach { note ->
note.lastReactionsDownloadTime.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
}
fun findMinimumEOSEsForUsers(users: List<User>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
users.forEach {
it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
}

View File

@@ -21,16 +21,18 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.StatusEvent
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()
object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
private var usersToWatch = setOf<User>()
fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
@@ -41,10 +43,10 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = firstTimers,
),
),
@@ -62,28 +64,32 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
.map { group ->
val groupIds = group.map { it.pubkeyHex }
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
),
),
)
}
.flatten()
if (groupIds.isNotEmpty()) {
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
),
),
)
} else {
listOf()
}
}.flatten()
}
val userChannel =
@@ -91,13 +97,11 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
checkNotInMainThread()
usersToWatch.forEach {
if (it.latestMetadata != null) {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
@@ -109,8 +113,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
listOfNotNull(
createUserMetadataFilter(),
createUserMetadataStatusReportFilter(),
)
.flatten()
).flatten()
.ifEmpty { null }
}

View File

@@ -21,11 +21,11 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
@@ -45,7 +45,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
ids = eventsToLoad.toList(),
),
)

View File

@@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AppRecommendationEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
@@ -39,10 +39,14 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(user: User?) {
@@ -54,7 +58,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -67,7 +71,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -79,6 +83,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
PinListEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
WikiNoteEvent.KIND,
),
authors = listOf(it.pubkeyHex),
limit = 200,
@@ -86,14 +91,32 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
)
}
fun createUserPostsFilter2() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TorrentEvent.KIND,
TorrentCommentEvent.KIND,
),
authors = listOf(it.pubkeyHex),
limit = 20,
),
)
}
fun createUserReceivedZapsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 200,
),
)
}
@@ -103,7 +126,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -116,7 +139,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
@@ -128,7 +151,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(BadgeProfilesEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -141,7 +164,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds =
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
authors = listOf(it.pubkeyHex),
@@ -150,12 +173,26 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
)
}
fun createProfileGalleryFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(ProfileGalleryEntryEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1000,
),
)
}
fun createReceivedAwardsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
SincePerRelayFilter(
kinds = listOf(BadgeAwardEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20,
@@ -170,13 +207,14 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createUserPostsFilter2(),
createProfileGalleryFilter(),
createFollowFilter(),
createFollowersFilter(),
createUserReceivedZapsFilter(),
createAcceptedAwardsFilter(),
createReceivedAwardsFilter(),
createBookmarksFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
}
}

View File

@@ -23,16 +23,22 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif")
val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet()
object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
@@ -40,8 +46,6 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
var job: Job? = null
val SUPPORTED_VIDEO_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav")
override fun start() {
job?.cancel()
job =
@@ -61,76 +65,82 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
}
fun createContextualFilter(): TypedFilter {
val follows = account.liveStoriesFollowLists.value?.users?.toList()
val follows = account.liveStoriesListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
tags = mapOf("m" to SUPPORTED_VIDEO_MIME_TYPES),
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null
val hashToLoad =
account.liveStoriesFollowLists.value
?.hashtags
?.toList() ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null
val hashToLoad =
account.liveStoriesFollowLists.value
?.geotags
?.toList() ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
)
@@ -140,7 +150,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultStoriesFollowList.value,
account.settings.defaultStoriesFollowList.value,
relayUrl,
time,
)
@@ -152,7 +162,6 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
createContextualFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
}
}

View File

@@ -24,6 +24,7 @@ import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.CryptoUtils
import okhttp3.EventListener
import okhttp3.Protocol
@@ -31,7 +32,10 @@ import okhttp3.Request
import okio.ByteString.Companion.toByteString
import kotlin.coroutines.cancellation.CancellationException
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
@Immutable data class OnlineCheckResult(
val timeInMs: Long,
val online: Boolean,
)
object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
@@ -57,7 +61,8 @@ object OnlineChecker {
val result =
if (url.startsWith("wss")) {
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.replace("wss+livekit://", "wss://"))
.header("Upgrade", "websocket")
@@ -68,7 +73,9 @@ object OnlineChecker {
.build()
val client =
HttpClientManager.getHttpClient().newBuilder()
HttpClientManager
.getHttpClient()
.newBuilder()
.eventListener(EventListener.NONE)
.protocols(listOf(Protocol.HTTP_1_1))
.build()
@@ -79,7 +86,8 @@ object OnlineChecker {
}
} else {
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()

View File

@@ -28,15 +28,12 @@ object PackageUtils {
private fun isPackageInstalled(
context: Context,
target: String,
): Boolean {
return context.packageManager.getInstalledApplications(0).find { info ->
): Boolean =
context.packageManager.getInstalledApplications(0).find { info ->
info.packageName == target
} != null
}
fun isOrbotInstalled(context: Context): Boolean {
return isPackageInstalled(context, "org.torproject.android")
}
fun isOrbotInstalled(context: Context): Boolean = isPackageInstalled(context, "org.torproject.android")
fun isExternalSignerInstalled(context: Context): Boolean {
val intent =

View File

@@ -0,0 +1,389 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.round
class ZapPaymentHandler(
val account: Account,
) {
@Immutable
data class Payable(
val info: ZapSplitSetup,
val user: User?,
val amountMilliSats: Long,
val invoice: String,
)
suspend fun zap(
note: Note,
amountMilliSats: Long,
pollOption: Int?,
message: String,
context: Context,
showErrorIfNoLnAddress: Boolean,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
zapType: LnZapEvent.ZapType,
) = withContext(Dispatchers.IO) {
val noteEvent = note.event
val zapSplitSetup = noteEvent?.zapSplitSetup()
val zapsToSend =
if (!zapSplitSetup.isNullOrEmpty()) {
zapSplitSetup
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) }
} else if (noteEvent is AppDefinitionEvent) {
val appLud16 = noteEvent.appMetaData()?.lnAddress()
if (appLud16 != null) {
listOf(ZapSplitSetup(appLud16, null, weight = 1.0, true))
} else {
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
if (showErrorIfNoLnAddress) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
}
return@withContext
}
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
} else {
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
if (showErrorIfNoLnAddress) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
}
return@withContext
}
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
onProgress(0.02f)
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
if (splitZapRequestPairs.isEmpty()) {
onProgress(0.00f)
return@signAllZapRequests
} else {
onProgress(0.05f)
}
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, onError, onProgress = {
onProgress(it * 0.7f + 0.05f) // keeps within range.
}, context) {
if (it.isEmpty()) {
onProgress(0.00f)
return@assembleAllInvoices
} else {
onProgress(0.75f)
}
if (account.hasWalletConnectSetup()) {
payViaNWC(it.values.map { it.invoice }, note, onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context) {
// onProgress(1f)
}
} else {
onPayViaIntent(
it
.map {
Payable(
info = it.key.first,
user = it.key.second.user,
amountMilliSats = it.value.zapValue,
invoice = it.value.invoice,
)
}.toImmutableList(),
)
onProgress(0f)
}
}
}
}
private fun calculateZapValue(
amountMilliSats: Long,
weight: Double,
totalWeight: Double,
): Long {
val shareValue = amountMilliSats * (weight / totalWeight)
val roundedZapValue = round(shareValue / 1000f).toLong() * 1000
return roundedZapValue
}
class SignAllZapRequestsReturn(
val zapRequestJson: String,
val user: User? = null,
)
suspend fun signAllZapRequests(
note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
zapsToSend: List<ZapSplitSetup>,
onAllDone: suspend (MutableMap<ZapSplitSetup, SignAllZapRequestsReturn>) -> Unit,
) {
val authorRelayList =
note.author
?.pubkeyHex
?.let {
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(it),
)?.event as? AdvertisedRelayListEvent?
)?.readRelays()
}?.toSet()
collectSuccessfulSigningOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
operationsInput = zapsToSend,
runRequestFor = { next: ZapSplitSetup, onReady ->
if (next.isLnAddress) {
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(SignAllZapRequestsReturn(zapRequestJson))
}
}
} else {
val user = LocalCache.getUserIfExists(next.lnAddressOrPubKeyHex)
val userRelayList =
(
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(next.lnAddressOrPubKeyHex),
)?.event as? AdvertisedRelayListEvent?
)?.readRelays()?.toSet() ?: emptySet()
) + (authorRelayList ?: emptySet())
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
}
}
}
},
onReady = onAllDone,
)
}
suspend fun assembleAllInvoices(
invoices: List<Pair<ZapSplitSetup, SignAllZapRequestsReturn>>,
totalAmountMilliSats: Long,
message: String,
showErrorIfNoLnAddress: Boolean,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
onAllDone: suspend (MutableMap<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>) -> Unit,
) {
var progressAllPayments = 0.00f
val totalWeight = invoices.sumOf { it.first.weight }
collectSuccessfulSigningOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
operationsInput = invoices,
runRequestFor = { splitZapRequestPair: Pair<ZapSplitSetup, SignAllZapRequestsReturn>, onReady ->
assembleInvoice(
splitSetup = splitZapRequestPair.first,
nostrZapRequest = splitZapRequestPair.second.zapRequestJson,
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
message = message,
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
onError = onError,
onProgressStep = { percentStepForThisPayment ->
progressAllPayments += percentStepForThisPayment / invoices.size
onProgress(progressAllPayments)
},
context = context,
onReady = onReady,
)
},
onReady = onAllDone,
)
}
suspend fun payViaNWC(
invoices: List<String>,
note: Note,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
onAllDone: suspend (MutableMap<String, Boolean>) -> Unit,
) {
var progressAllPayments = 0.00f
collectSuccessfulSigningOperations<String, Boolean>(
operationsInput = invoices,
runRequestFor = { invoice: String, onReady ->
account.sendZapPaymentRequestFor(
bolt11 = invoice,
zappedNote = note,
onSent = {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
onReady(true)
},
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
stringRes(
context,
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
)
} else {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
}
},
)
},
onReady = onAllDone,
)
}
class AssembleInvoiceReturn(
val zapValue: Long,
val invoice: String,
)
private fun assembleInvoice(
splitSetup: ZapSplitSetup,
nostrZapRequest: String,
zapValue: Long,
message: String,
showErrorIfNoLnAddress: Boolean = true,
onError: (String, String) -> Unit,
onProgressStep: (percent: Float) -> Unit,
context: Context,
onReady: (AssembleInvoiceReturn) -> Unit,
) {
var progressThisPayment = 0.00f
var user: User? = null
val lud16 =
if (splitSetup.isLnAddress) {
splitSetup.lnAddressOrPubKeyHex
} else {
user = LocalCache.getUserIfExists(splitSetup.lnAddressOrPubKeyHex)
user?.info?.lnAddress()
}
if (lud16 != null) {
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
milliSats = zapValue,
message = message,
nostrRequest = nostrZapRequest,
onError = onError,
onProgress = {
val step = it - progressThisPayment
progressThisPayment = it
onProgressStep(step)
},
context = context,
onSuccess = {
onProgressStep(1 - progressThisPayment)
onReady(AssembleInvoiceReturn(zapValue, it))
},
)
} else {
if (showErrorIfNoLnAddress) {
onError(
stringRes(
context,
R.string.missing_lud16,
),
stringRes(
context,
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: splitSetup.lnAddressOrPubKeyHex,
),
)
}
}
}
private fun prepareZapRequestIfNeeded(
note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
additionalRelays: Set<String>? = null,
onReady: (String?) -> Unit,
) {
if (zapType != LnZapEvent.ZapType.NONZAP) {
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest ->
println("Zap Request " + zapRequest.toJson())
onReady(zapRequest.toJson())
}
} else {
onReady(null)
}
}
}

View File

@@ -27,14 +27,15 @@ import kotlinx.coroutines.CancellationException
import java.text.NumberFormat
@Stable
data class InvoiceAmount(val invoice: String, val amount: String?)
data class InvoiceAmount(
val invoice: String,
val amount: String?,
)
object CachedLnInvoiceParser {
val lnInvoicesCache = LruCache<String, InvoiceAmount>(20)
fun cached(lnurl: String): InvoiceAmount? {
return lnInvoicesCache[lnurl]
}
fun cached(lnurl: String): InvoiceAmount? = lnInvoicesCache[lnurl]
fun parse(lnbcWord: String): InvoiceAmount? {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)

View File

@@ -24,18 +24,18 @@ import android.content.Context
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toLnUrl
import okhttp3.Request
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException
class LightningAddressResolver() {
class LightningAddressResolver {
val client = HttpClientManager.getHttpClient()
fun assembleUrl(lnaddress: String): String? {
@@ -64,8 +64,9 @@ class LightningAddressResolver() {
if (url == null) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
lnaddress,
),
@@ -75,7 +76,8 @@ class LightningAddressResolver() {
try {
val request: Request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
@@ -85,8 +87,9 @@ class LightningAddressResolver() {
onSuccess(it.body.string())
} else {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
@@ -100,8 +103,9 @@ class LightningAddressResolver() {
if (e is CancellationException) throw e
e.printStackTrace()
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
url,
@@ -134,7 +138,8 @@ class LightningAddressResolver() {
}
val request: Request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
@@ -144,27 +149,13 @@ class LightningAddressResolver() {
onSuccess(it.body.string())
} else {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.could_not_fetch_invoice_from, lnCallback),
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_fetch_invoice_from, lnCallback),
)
}
}
}
fun lnAddressToLnUrl(
lnaddress: String,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
fetchLightningAddressJson(
lnaddress,
onSuccess = { onSuccess(it.toByteArray().toLnUrl()) },
onError = onError,
context = context,
)
}
fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
@@ -188,9 +179,11 @@ class LightningAddressResolver() {
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup,
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
lnaddress,
),
)
null
@@ -200,9 +193,11 @@ class LightningAddressResolver() {
if (callback == null) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration,
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
lnaddress,
),
)
}
@@ -224,10 +219,12 @@ class LightningAddressResolver() {
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup,
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
lnaddress,
),
)
null
@@ -248,8 +245,9 @@ class LightningAddressResolver() {
} else {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnaddress,
@@ -265,10 +263,12 @@ class LightningAddressResolver() {
?.let { reason ->
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
lnaddress,
reason,
),
)
@@ -276,10 +276,12 @@ class LightningAddressResolver() {
?: run {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json,
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
lnaddress,
),
)
}

View File

@@ -0,0 +1,370 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException
class EventNotificationConsumer(
private val applicationContext: Context,
) {
companion object {
const val TAG = "EventNotificationConsumer"
}
suspend fun consume(event: GiftWrapEvent) {
Log.d(TAG, "New Notification Arrived")
if (!LocalCache.justVerify(event)) return
if (!notificationManager().areNotificationsEnabled()) return
// PushNotification Wraps don't include a receiver.
// Test with all logged in accounts
var matchAccount = false
LocalPreferences.allSavedAccounts().forEach {
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) {
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
Log.d(TAG, "New Notification Testing if for ${it.npub}")
try {
consumeIfMatchesAccount(event, acc)
matchAccount = true
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}")
}
}
}
}
}
private suspend fun consumeIfMatchesAccount(
pushWrappedEvent: GiftWrapEvent,
account: AccountSettings,
) {
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
val signer = account.createSigner()
pushWrappedEvent.unwrapThrowing(signer) { notificationEvent ->
val consumed = LocalCache.hasConsumed(notificationEvent)
val verified = LocalCache.justVerify(notificationEvent)
Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified")
if (!consumed && verified) {
Log.d(TAG, "New Notification was verified")
unwrapAndConsume(notificationEvent, signer) { innerEvent ->
Log.d(TAG, "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
if (innerEvent is PrivateDmEvent) {
Log.d(TAG, "New Nip-04 DM to Notify")
notify(innerEvent, signer, account)
} else if (innerEvent is LnZapEvent) {
Log.d(TAG, "New Zap to Notify")
notify(innerEvent, signer, account)
} else if (innerEvent is ChatMessageEvent) {
Log.d(TAG, "New ChatMessage to Notify")
notify(innerEvent, signer, account)
}
}
}
}
}
private fun unwrapAndConsume(
event: Event,
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
if (!LocalCache.justVerify(event)) return
if (LocalCache.hasConsumed(event)) return
when (event) {
is GiftWrapEvent -> {
event.unwrap(signer) {
unwrapAndConsume(it, signer, onReady)
LocalCache.justConsume(event, null)
}
}
is SealedGossipEvent -> {
event.unseal(signer) {
if (!LocalCache.hasConsumed(it)) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
}
LocalCache.justConsume(event, null)
}
}
else -> {
LocalCache.justConsume(event, null)
onReady(event)
}
}
}
private fun notify(
event: ChatMessageEvent,
signer: NostrSigner,
acc: AccountSettings,
) {
if (
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
// old event being re-broadcasted
event.pubKey != signer.pubKey
) { // from the user
Log.d(TAG, "Notifying")
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
val chatRoom = event.chatroomKey(signer.pubKey)
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
val isKnownRoom =
(
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
myUser.hasSentMessagesTo(chatRoom)
)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent()
notificationManager()
.sendDMNotification(
event.id,
content,
user,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
}
}
private fun notify(
event: PrivateDmEvent,
signer: NostrSigner,
acc: AccountSettings,
) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
if (signer.pubKey == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
val chatRoom = event.chatroomKey(signer.pubKey)
val isKnownRoom =
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
myUser.hasSentMessagesTo(chatRoom)
if (isKnownRoom) {
note.author?.let {
decryptContent(note, signer) { content ->
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager()
.sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext)
}
}
}
}
}
fun decryptZapContentAuthor(
note: Note,
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
val event = note.event
if (event is LnZapRequestEvent) {
if (event.isPrivateZap()) {
event.decryptPrivateZap(signer) { onReady(it) }
} else {
onReady(event)
}
}
}
fun decryptContent(
note: Note,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
val event = note.event
if (event is PrivateDmEvent) {
event.plainContent(signer, onReady)
} else if (event is LnZapRequestEvent) {
decryptZapContentAuthor(note, signer) { onReady(it.content) }
} else if (event is DraftEvent) {
event.cachedDraft(signer) {
onReady(it.content)
}
} else {
event?.content()?.let { onReady(it) }
}
}
private fun notify(
event: LnZapEvent,
signer: NostrSigner,
acc: AccountSettings,
) {
Log.d(TAG, "Notify Start ${event.toNostrUri()}")
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
Log.d(TAG, "Notify Not Notified Yet")
// old event being re-broadcast
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
Log.d(TAG, "Notify Not an old event")
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped =
event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
Log.d(TAG, "Notify ZapRequest $noteZapRequest zapped $noteZapped")
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
Log.d(TAG, "Notify Amount Bigger than 10")
if (event.isTaggedUser(signer.pubKey)) {
val amount = showAmount(event.amount)
Log.d(TAG, "Notify Amount $amount")
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
decryptZapContentAuthor(noteZapRequest, signer) {
Log.d(TAG, "Notify Decrypted if Private Zap ${event.id}")
val author = LocalCache.getOrCreateUser(it.pubKey)
val senderInfo = Pair(author, it.content.ifBlank { null })
if (noteZapped.event?.content() != null) {
decryptContent(noteZapped, signer) {
Log.d(TAG, "Notify Decrypted if Private Note")
val zappedContent = it.split("\n").get(0)
val user = senderInfo.first.toBestDisplayName()
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
var content =
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_from,
user,
)
zappedContent.let {
content +=
" " +
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_for,
zappedContent,
)
}
val userPicture = senderInfo.first.profilePicture()
val noteUri = "nostr:Notifications"
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
notificationManager()
.sendZapNotification(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
} else {
// doesn't have a base note to refer to.
Log.d(TAG, "Notify Zapped note not available")
val user = senderInfo.first.toBestDisplayName()
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
val content =
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_from,
user,
)
val userPicture = senderInfo.first.profilePicture()
val noteUri = "nostr:Notifications"
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
notificationManager()
.sendZapNotification(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
}
}
}
}
fun notificationManager(): NotificationManager =
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
as NotificationManager
}

View File

@@ -34,6 +34,7 @@ import coil.executeBlocking
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
object NotificationUtils {
private var dmChannel: NotificationChannel? = null
@@ -46,14 +47,13 @@ object NotificationUtils {
dmChannel =
NotificationChannel(
applicationContext.getString(R.string.app_notification_dms_channel_id),
applicationContext.getString(R.string.app_notification_dms_channel_name),
stringRes(applicationContext, R.string.app_notification_dms_channel_id),
stringRes(applicationContext, R.string.app_notification_dms_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
.apply {
description =
applicationContext.getString(R.string.app_notification_dms_channel_description)
}
).apply {
description =
stringRes(applicationContext, R.string.app_notification_dms_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
@@ -69,14 +69,13 @@ object NotificationUtils {
zapChannel =
NotificationChannel(
applicationContext.getString(R.string.app_notification_zaps_channel_id),
applicationContext.getString(R.string.app_notification_zaps_channel_name),
stringRes(applicationContext, R.string.app_notification_zaps_channel_id),
stringRes(applicationContext, R.string.app_notification_zaps_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
.apply {
description =
applicationContext.getString(R.string.app_notification_zaps_channel_description)
}
).apply {
description =
stringRes(applicationContext, R.string.app_notification_zaps_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
@@ -91,17 +90,19 @@ object NotificationUtils {
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
val channelId = stringRes(applicationContext, R.string.app_notification_zaps_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
time,
pictureUrl,
uri,
channelId,
@@ -114,17 +115,19 @@ object NotificationUtils {
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
time,
pictureUrl,
uri,
channelId,
@@ -137,6 +140,7 @@ object NotificationUtils {
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
channelId: String,
@@ -152,6 +156,7 @@ object NotificationUtils {
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = imageResult.drawable as? BitmapDrawable,
uri = uri,
channelId,
@@ -163,6 +168,7 @@ object NotificationUtils {
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = null,
uri = uri,
channelId,
@@ -176,6 +182,7 @@ object NotificationUtils {
id: String,
messageBody: String,
messageTitle: String,
time: Long,
picture: BitmapDrawable?,
uri: String,
channelId: String,
@@ -205,13 +212,13 @@ object NotificationUtils {
// Build the notification
val builderPublic =
NotificationCompat.Builder(
applicationContext,
channelId,
)
.setSmallIcon(R.drawable.amethyst)
NotificationCompat
.Builder(
applicationContext,
channelId,
).setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
@@ -219,14 +226,15 @@ object NotificationUtils {
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setWhen(time * 1000)
// Build the notification
val builder =
NotificationCompat.Builder(
applicationContext,
channelId,
)
.setSmallIcon(R.drawable.amethyst)
NotificationCompat
.Builder(
applicationContext,
channelId,
).setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
@@ -237,6 +245,7 @@ object NotificationUtils {
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setWhen(time * 1000)
notify(notId, builder.build())
}

View File

@@ -24,22 +24,35 @@ import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.coroutines.resume
class RegisterAccounts(
private val accounts: List<AccountInfo>,
) {
private fun recursiveAuthCreation(
val tag =
if (BuildConfig.FLAVOR == "play") {
"RegisterAccounts FirebaseMsgService"
} else {
"RegisterAccounts UnifiedPushService"
}
private suspend fun signAllAuths(
notificationToken: String,
remainingTos: List<Pair<Account, String>>,
remainingTos: List<Pair<AccountSettings, List<String>>>,
output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
@@ -48,12 +61,35 @@ class RegisterAccounts(
return
}
val next = remainingTos.first()
coroutineScope {
val jobs =
remainingTos.map { accountRelayPair ->
async {
val result =
withTimeoutOrNull(10000) {
suspendCancellableCoroutine { continuation ->
val signer = accountRelayPair.first.createSigner()
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
continuation.resume(result)
}
}
}
next.first.createAuthEvent(next.second, notificationToken) {
output.add(it)
recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady)
if (result != null) {
output.add(result)
}
}
}
// runs in parallel to avoid overcrowding Amber.
withTimeoutOrNull(15000) {
jobs.joinAll()
}
}
onReady(output)
}
// creates proof that it controls all accounts
@@ -63,25 +99,38 @@ class RegisterAccounts(
onReady: (List<RelayAuthEvent>) -> Unit,
) {
val readyToSend =
accounts.mapNotNull {
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val readRelays =
acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()
accounts
.mapNotNull {
Log.d(tag, "Register Account ${it.npub}")
val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null }
if (relayToUse != null) {
Pair(acc, relayToUse)
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList()
Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}")
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList<String>()
Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}")
val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
val relays = (nip65Read + nip17Read + readKind3Relays)
if (relays.isNotEmpty()) {
Pair(acc, relays)
} else {
null
}
} else {
null
}
} else {
null
}
}
val listOfAuthEvents = mutableListOf<RelayAuthEvent>()
recursiveAuthCreation(
signAllAuths(
notificationToken,
readyToSend,
listOfAuthEvents,
@@ -101,7 +150,8 @@ class RegisterAccounts(
val body = jsonObject.toRequestBody(mediaType)
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://push.amethyst.social/register")
.post(body)
@@ -110,22 +160,20 @@ class RegisterAccounts(
val client = HttpClientManager.getHttpClient()
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
Log.i(tag, "Server registration $isSucess")
} catch (e: java.lang.Exception) {
if (e is CancellationException) throw e
val tag =
if (BuildConfig.FLAVOR == "play") {
"FirebaseMsgService"
} else {
"UnifiedPushService"
}
Log.e(tag, "Unable to register with push server", e)
}
}
suspend fun go(notificationToken: String) =
withContext(Dispatchers.IO) {
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
suspend fun go(notificationToken: String) {
if (notificationToken.isNotEmpty()) {
withContext(Dispatchers.IO) {
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
PushNotificationUtils.hasInit = true
PushNotificationUtils.hasInit = true
}
}
}
}

View File

@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.ots
import android.util.Log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.ots.BitcoinExplorer
import com.vitorpamplona.quartz.ots.BlockHeader
import com.vitorpamplona.quartz.ots.exceptions.UrlException
@@ -42,7 +42,8 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
val url = "$BLOCKSTREAM_API_URL/block/$hash"
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/json")
.url(url)
@@ -79,7 +80,8 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
val url = "$BLOCKSTREAM_API_URL/block-height/$height"
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()

View File

@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.ots.ICalendar
import com.vitorpamplona.quartz.ots.StreamDeserializationContext
@@ -30,14 +30,15 @@ import com.vitorpamplona.quartz.ots.exceptions.CommitmentNotFoundException
import com.vitorpamplona.quartz.ots.exceptions.DeserializationException
import com.vitorpamplona.quartz.ots.exceptions.ExceededSizeException
import com.vitorpamplona.quartz.ots.exceptions.UrlException
import com.vitorpamplona.quartz.ots.http.Request
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Class representing remote calendar server interface.
*/
class OkHttpCalendar(val url: String) : ICalendar {
class OkHttpCalendar(
val url: String,
) : ICalendar {
/**
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
*
@@ -57,7 +58,8 @@ class OkHttpCalendar(val url: String) : ICalendar {
val requestBody = digest.toRequestBody(mediaType)
val request =
okhttp3.Request.Builder()
okhttp3.Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
@@ -104,7 +106,8 @@ class OkHttpCalendar(val url: String) : ICalendar {
val url = url + "/timestamp/" + Hex.encode(commitment)
val request =
okhttp3.Request.Builder()
okhttp3.Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")

View File

@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
import com.vitorpamplona.quartz.ots.StreamDeserializationContext
import com.vitorpamplona.quartz.ots.Timestamp
@@ -33,7 +33,10 @@ import java.util.concurrent.BlockingQueue
/**
* For making async calls to a calendar server
*/
class OkHttpCalendarAsyncSubmit(private val url: String, private val digest: ByteArray) : ICalendarAsyncSubmit {
class OkHttpCalendarAsyncSubmit(
private val url: String,
private val digest: ByteArray,
) : ICalendarAsyncSubmit {
private var queue: BlockingQueue<Optional<Timestamp>>? = null
fun setQueue(queue: BlockingQueue<Optional<Timestamp>>?) {
@@ -49,7 +52,8 @@ class OkHttpCalendarAsyncSubmit(private val url: String, private val digest: Byt
val requestBody = digest.toRequestBody(mediaType)
val request =
okhttp3.Request.Builder()
okhttp3.Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")

View File

@@ -25,14 +25,10 @@ import com.vitorpamplona.quartz.ots.ICalendar
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
class OkHttpCalendarBuilder : CalendarBuilder {
override fun newSyncCalendar(url: String): ICalendar {
return OkHttpCalendar(url)
}
override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url)
override fun newAsyncCalendar(
url: String,
digest: ByteArray,
): ICalendarAsyncSubmit {
return OkHttpCalendarAsyncSubmit(url, digest)
}
): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest)
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.ammolite.service.HttpClientManager
/**
* HLS LiveStreams cannot use cache.
*/
@UnstableApi
class CustomMediaSourceFactory : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
private var nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
nonCachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
return this
}
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
cachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
nonCachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
return this
}
override fun getSupportedTypes(): IntArray = nonCachingFactory.supportedTypes
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
if (mediaItem.mediaId.contains(".m3u8", true)) {
return nonCachingFactory.createMediaSource(mediaItem)
}
return cachingFactory.createMediaSource(mediaItem)
}
}

View File

@@ -68,14 +68,13 @@ class MultiPlayerPlaybackManager(
private fun getCallbackIntent(
callbackUri: String,
applicationContext: Context,
): PendingIntent {
return PendingIntent.getActivity(
): PendingIntent =
PendingIntent.getActivity(
applicationContext,
0,
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun getMediaSession(
@@ -109,13 +108,18 @@ class MultiPlayerPlaybackManager(
player.addListener(
object : Player.Listener {
// avoids saving positions for live streams otherwise caching goes crazy
val mustCachePositions = !uri.contains(".m3u8", true)
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
player.setWakeMode(C.WAKE_MODE_NETWORK)
playingMap.put(id, mediaSession)
} else {
player.setWakeMode(C.WAKE_MODE_NONE)
cachedPositions.add(uri, player.currentPosition)
if (mustCachePositions) {
cachedPositions.add(uri, player.currentPosition)
}
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
@@ -125,20 +129,22 @@ class MultiPlayerPlaybackManager(
when (playbackState) {
STATE_IDLE -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
if (mustCachePositions && abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
if (mustCachePositions) {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
}
}
}
}
else -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
if (mustCachePositions && abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
@@ -150,7 +156,9 @@ class MultiPlayerPlaybackManager(
newPosition: PositionInfo,
reason: Int,
) {
cachedPositions.add(uri, newPosition.positionMs)
if (mustCachePositions && player.playbackState != STATE_IDLE) {
cachedPositions.add(uri, newPosition.positionMs)
}
}
},
)
@@ -172,7 +180,5 @@ class MultiPlayerPlaybackManager(
}
}
fun playingContent(): Collection<MediaSession> {
return playingMap.values
}
fun playingContent(): Collection<MediaSession> = playingMap.values
}

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