Compare commits

..
271 Commits
Author SHA1 Message Date
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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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
KotlinGeekDevandKotlinGeekDev 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
KotlinGeekDevandKotlinGeekDev 0f817dbf00 Put device in landscape mode when fullscreen button is pressed. 2024-08-17 14:18:48 +00:00
KotlinGeekDevandKotlinGeekDev 7ab6322886 Introduce DeviceUtils, and add functions for changing orientation, and checking for Landscape mode. 2024-08-17 14:18:48 +00:00
KotlinGeekDevandKotlinGeekDev 223c0c1263 Initial implementation for auto-fullscreen on rotation to landscape mode. 2024-08-17 14:18:48 +00:00
KotlinGeekDevandKotlinGeekDev 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub bb127506c7 Merge pull request #985 from believethehype/main
Store additional File Metadata in Gallery Entries
2024-07-22 08:10:26 -04:00
Vitor PamplonaandGitHub 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
greenart7c3andGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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
greenart7c3andGitHub 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
greenart7c3andgreenart7c3 80c369a6ae change default timeout to 15 seconds 2024-07-19 10:56:35 -03:00
greenart7c3andgreenart7c3 d0190c87a2 add more logs, add optional listener, fix await call 2024-07-19 10:56:35 -03:00
greenart7c3andgreenart7c3 62a2cba18b fix lint 2024-07-19 10:56:35 -03:00
greenart7c3andgreenart7c3 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 PamplonaandGitHub 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
316 changed files with 10640 additions and 5787 deletions
+9 -3
View File
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}
android {
@@ -15,9 +16,9 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 388
versionName "0.89.2"
buildConfigField "String", "RELEASE_NOTES_ID", "\"1130badb252bdf62aaf93cb65bfa2bb09e7600c7d6764894f2954555b3b73018\""
versionCode 399
versionName "0.90.2"
buildConfigField "String", "RELEASE_NOTES_ID", "\"5d9a4fd0aba7ffefbf989152479237bd182578e58e59eb2fbfe94f6de8011803\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -163,6 +164,7 @@ kotlin {
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
@@ -251,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
@@ -282,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
@@ -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)
}
}
@@ -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,6 +81,7 @@ class ImageUploadTesting {
sensitiveContent = null,
serverInfo,
onProgress = {},
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
val url = result.tags!!.first { it[0] == "url" }.get(1)
@@ -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
@@ -133,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()
}
@@ -33,10 +33,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 +43,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
@@ -111,7 +108,6 @@ class PushMessageReceiver : MessagingReceiver() {
instance: String,
) {
Log.d(TAG, "Registration failed for Instance: $instance")
scope.cancel()
pushHandler.forceRemoveDistributor(context)
}
@@ -29,7 +29,7 @@ object PushNotificationUtils {
private val pushHandler = PushDistributorHandler
suspend fun init(accounts: List<AccountInfo>) {
if (hasInit || pushHandler.savedDistributorExists()) {
if (hasInit) {
return
}
try {
@@ -55,10 +55,10 @@ 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) {
@@ -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 }
@@ -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)"
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.os.Looper
import android.os.StrictMode
@@ -30,12 +31,14 @@ 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
@@ -48,6 +51,9 @@ 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()
@@ -66,10 +72,18 @@ class Amethyst : Application() {
.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()
@@ -106,10 +120,27 @@ class Amethyst : Application() {
}
}
fun imageLoaderBuilder(): ImageLoader.Builder = ImageLoader.Builder(applicationContext).diskCache { coilCache }
fun imageLoaderBuilder(): ImageLoader.Builder =
ImageLoader
.Builder(this)
.diskCache { coilCache }
.memoryCache { coilMemCache }
.crossfade(true)
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 {
lateinit var instance: Amethyst
private set
@@ -26,7 +26,7 @@ import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
@@ -47,9 +47,11 @@ 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.signers.ExternalSignerLauncher
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
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
@@ -91,9 +93,13 @@ private object PrefKeys {
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
@@ -117,58 +123,68 @@ object LocalPreferences {
private var currentAccount: String? = null
private var savedAccounts: List<AccountInfo>? = null
private var cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
private var cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
suspend fun currentAccount(): String? {
if (currentAccount == null) {
currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
currentAccount =
withContext(Dispatchers.IO) {
encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
}
return currentAccount
}
private fun updateCurrentAccount(npub: String?) {
private suspend fun updateCurrentAccount(npub: String?) {
if (npub == null) {
currentAccount = null
encryptedPreferences().edit().clear().apply()
withContext(Dispatchers.IO) {
encryptedPreferences().edit().clear().apply()
}
} else if (currentAccount != npub) {
currentAccount = npub
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
withContext(Dispatchers.IO) {
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
}
}
}
private fun savedAccounts(): List<AccountInfo> {
private suspend fun savedAccounts(): List<AccountInfo> {
if (savedAccounts == null) {
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(),
)
withContext(Dispatchers.IO) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
Event.mapper.readValue<List<AccountInfo>>(it)
}
savedAccounts = migrated
if (!newSystemOfAccounts.isNullOrEmpty()) {
savedAccounts = newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply()
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) {
@@ -189,35 +205,33 @@ object LocalPreferences {
updateSavedAccounts(accounts)
}
private suspend fun setCurrentAccount(account: Account) =
withContext(Dispatchers.IO) {
val npub = account.userProfile().pubkeyNpub()
val accInfo =
AccountInfo(
npub,
account.isWriteable(),
account.signer is NostrSignerExternal,
)
updateCurrentAccount(npub)
addAccount(accInfo)
}
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) = withContext(Dispatchers.IO) { updateCurrentAccount(accountInfo.npub) }
suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo.npub)
/** Removes the account from the app level shared preferences */
private suspend fun removeAccount(accountInfo: AccountInfo) {
val accounts = savedAccounts().filter { it.npub != accountInfo.npub }
updateSavedAccounts(accounts)
updateSavedAccounts(savedAccounts().filter { it.npub != accountInfo.npub })
}
/** Deletes the npub-specific shared preference file */
private fun deleteUserPreferenceFile(npub: String) {
checkNotInMainThread()
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
File(prefsDir, it).delete()
private suspend fun deleteUserPreferenceFile(npub: String) {
withContext(Dispatchers.IO) {
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
File(prefsDir, it).delete()
}
}
}
}
@@ -246,8 +260,7 @@ object LocalPreferences {
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout")
withContext(Dispatchers.IO) {
val userPrefs = encryptedPreferences(accountInfo.npub)
userPrefs.edit().clear().commit()
encryptedPreferences(accountInfo.npub).edit().clear().commit()
removeAccount(accountInfo)
deleteUserPreferenceFile(accountInfo.npub)
@@ -259,117 +272,158 @@ object LocalPreferences {
}
}
suspend fun updatePrefsForLogin(account: Account) {
setCurrentAccount(account)
saveToEncryptedStorage(account)
suspend fun updatePrefsForLogin(accountSettings: AccountSettings) {
setCurrentAccount(accountSettings)
saveToEncryptedStorage(accountSettings)
}
fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
suspend fun saveToEncryptedStorage(account: Account) {
suspend fun saveToEncryptedStorage(settings: AccountSettings) {
Log.d("LocalPreferences", "Saving to encrypted storage")
withContext(Dispatchers.IO) {
checkNotInMainThread()
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub())
prefs
.edit()
.apply {
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal)
if (account.signer is NostrSignerExternal) {
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null)
if (settings.externalSignerPackageName != null) {
remove(PrefKeys.NOSTR_PRIVKEY)
putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName)
putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName)
} else {
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
remove(PrefKeys.SIGNER_PACKAGE_NAME)
settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
}
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, account.localRelayServers)
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(account.languagePreferences),
Event.mapper.writeValueAsString(settings.languagePreferences),
)
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
putString(PrefKeys.TRANSLATE_TO, settings.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value))
putString(
PrefKeys.REACTION_CHOICES,
Event.mapper.writeValueAsString(account.reactionChoices),
Event.mapper.writeValueAsString(settings.reactionChoices.value),
)
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name)
putString(
PrefKeys.DEFAULT_FILE_SERVER,
Event.mapper.writeValueAsString(account.defaultFileServer),
Event.mapper.writeValueAsString(settings.defaultFileServer),
)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
putString(
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
account.defaultNotificationFollowList.value,
settings.defaultNotificationFollowList.value,
)
putString(
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
account.defaultDiscoveryFollowList.value,
settings.defaultDiscoveryFollowList.value,
)
putString(
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
Event.mapper.writeValueAsString(account.zapPaymentRequest),
Event.mapper.writeValueAsString(settings.zapPaymentRequest),
)
if (account.backupContactList != null) {
if (settings.backupContactList != null) {
putString(
PrefKeys.LATEST_CONTACT_LIST,
Event.mapper.writeValueAsString(account.backupContactList),
Event.mapper.writeValueAsString(settings.backupContactList),
)
} else {
remove(PrefKeys.LATEST_CONTACT_LIST)
}
if (account.backupDMRelayList != null) {
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(account.backupDMRelayList),
Event.mapper.writeValueAsString(settings.backupDMRelayList),
)
} else {
remove(PrefKeys.LATEST_DM_RELAY_LIST)
}
if (account.backupNIP65RelayList != null) {
if (settings.backupNIP65RelayList != null) {
putString(
PrefKeys.LATEST_NIP65_RELAY_LIST,
Event.mapper.writeValueAsString(account.backupNIP65RelayList),
Event.mapper.writeValueAsString(settings.backupNIP65RelayList),
)
} else {
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, account.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
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(account.lastReadPerRoute),
Event.mapper.writeValueAsString(regularMap),
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
if (account.showSensitiveContent.value == null) {
if (settings.showSensitiveContent.value == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent.value!!)
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!)
}
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(account.pendingAttestations.value),
Event.mapper.writeValueAsString(settings.pendingAttestations.value),
)
}.apply()
}
}
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
suspend fun saveSharedSettings(
sharedSettings: Settings,
@@ -402,39 +456,39 @@ object LocalPreferences {
val mutex = Mutex()
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? =
withContext(Dispatchers.IO) {
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 account = innerLoadCurrentAccountFromEncryptedStorage(npub)
account?.registerObservers()
val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub)
cachedAccounts.put(npub, account)
cachedAccounts.put(npub, accountSettings)
return@withContext account
return@withContext accountSettings
}
}
}
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? {
Log.d("LocalPreferences", "Load account from file")
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 loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null)
val localRelays =
getString(PrefKeys.RELAYS, "[]")?.let {
println("LocalRelays: $it")
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
}
?: setOf<RelaySetupInfo>()
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()
@@ -448,149 +502,27 @@ object LocalPreferences {
val defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices =
getString(PrefKeys.ZAP_AMOUNTS, "[]")
?.let { Event.mapper.readValue<List<Long>?>(it) }
?.ifEmpty { DefaultZapAmounts }
?: DefaultZapAmounts
val reactionChoices =
getString(PrefKeys.REACTION_CHOICES, "[]")
?.let { Event.mapper.readValue<List<String>?>(it) }
?.ifEmpty { DefaultReactions }
?: DefaultReactions
val defaultZapType =
getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.values().firstOrNull { it.name == serverName }
}
?: LnZapEvent.ZapType.PUBLIC
LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer =
try {
getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
Event.mapper.readValue<Nip96MediaServers.ServerName>(serverName)
}
?: Nip96MediaServers.DEFAULT[0]
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
e.printStackTrace()
Nip96MediaServers.DEFAULT[0]
}
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 zapPaymentRequestServer =
try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
Event.mapper.readValue<Nip47WalletConnect.Nip47URI?>(it)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}",
e,
)
e.printStackTrace()
null
}
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 latestContactList =
try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
if (it != "null") {
println("Decoding Contact List: $it")
Event.fromJson(it) as ContactListEvent?
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
e,
)
null
}
val latestDmRelayList =
try {
getString(PrefKeys.LATEST_DM_RELAY_LIST, null)?.let {
if (it != "null") {
println("Decoding DM Relay List: $it")
Event.fromJson(it) as ChatMessageRelayListEvent?
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding DM Relay List ${getString(PrefKeys.LATEST_DM_RELAY_LIST, null)}",
e,
)
null
}
val latestNip65RelayList =
try {
getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)?.let {
if (it != "null") {
println("Decoding NIP65 Relay List: $it")
Event.fromJson(it) as AdvertisedRelayListEvent?
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding NIP65 Relay List ${getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)}",
e,
)
null
}
val pendingAttestations =
try {
getString(PrefKeys.PENDING_ATTESTATIONS, null)?.let {
println("Decoding Attestation List: " + it)
if (it != null) {
Event.mapper.readValue<Map<HexKey, String>>(it)
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Contact List ${getString(PrefKeys.PENDING_ATTESTATIONS, null)}",
e,
)
null
}
val languagePreferences =
try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
Event.mapper.readValue<Map<String, String>?>(it)
}
?: mapOf()
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}",
e,
)
e.printStackTrace()
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)
@@ -609,81 +541,84 @@ object LocalPreferences {
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val lastReadPerRoute =
try {
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
Event.mapper.readValue<Map<String, Long>?>(it)
}
?: mapOf()
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}",
e,
)
e.printStackTrace()
mapOf()
}
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
MutableStateFlow(it.value)
} ?: mapOf()
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
val signer =
if (loginWithExternalSigner) {
val packageName =
getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner"
NostrSignerExternal(
pubKey,
ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName),
)
} else {
NostrSignerInternal(keyPair)
}
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val account =
Account(
keyPair = keyPair,
signer = signer,
localRelays = localRelays,
localRelayServers = localRelayServers,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
zapAmountChoices = zapAmountChoices,
reactionChoices = reactionChoices,
defaultZapType = defaultZapType,
defaultFileServer = defaultFileServer,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
backupContactList = latestContactList,
backupNIP65RelayList = latestNip65RelayList,
backupDMRelayList = latestDmRelayList,
proxy = proxy,
proxyPort = proxyPort,
showSensitiveContent = MutableStateFlow(showSensitiveContent),
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
hasDonatedInVersion = hasDonatedInVersion,
pendingAttestations = MutableStateFlow(pendingAttestations ?: emptyMap()),
)
// Loads from DB
account.userProfile()
withContext(Dispatchers.Main) {
// Loads Live Objects
account.userProfile().live()
}
return@with account
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
}
}
}
@@ -28,8 +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.Base64Fetcher
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -56,18 +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) {
@@ -85,9 +88,9 @@ class ServiceManager {
val myAccount = account
// Resets Proxy Use
HttpClientManager.setDefaultProxy(account?.proxy)
HttpClientManager.setDefaultProxy(account?.settings?.proxy)
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
Amethyst.instance
.imageLoaderBuilder()
@@ -98,8 +101,12 @@ 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()
@@ -124,20 +131,23 @@ class ServiceManager {
// 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()
@@ -188,7 +198,7 @@ class ServiceManager {
LocalCache.cleanObservers()
}
fun trimMemory() {
suspend fun trimMemory() {
LocalCache.cleanObservers()
val accounts =
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,525 @@
/**
* 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.BuildConfig
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 + BuildConfig.VERSION_NAME
}
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,
)
@@ -22,16 +22,14 @@ 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.ammolite.relays.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,
@@ -82,10 +80,10 @@ class AntiSpamFilter {
logOffender(hash, event)
if (relay != null) {
RelayStats.newSpam(relay.url, "https://njump.me/${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)}")
RelayStats.newSpam(relay.url, njumpLink(Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)))
}
liveSpam.invalidateData()
flowSpam.tryEmit(AntiSpamState(this))
return true
}
@@ -108,27 +106,7 @@ class AntiSpamFilter {
}
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(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))
}
}
}
val flowSpam = MutableStateFlow<AntiSpamState>(AntiSpamState(this))
}
class AntiSpamState(
@@ -22,7 +22,9 @@ 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
@@ -75,7 +77,7 @@ class Chatroom {
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()) {
@@ -84,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
@@ -114,6 +114,8 @@ 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
@@ -463,6 +465,61 @@ 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,
@@ -795,6 +852,8 @@ object LocalCache {
.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
is TorrentCommentEvent ->
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is DraftEvent -> {
event.mapTaggedEvent { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) }
@@ -1303,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 =
@@ -1328,6 +1391,9 @@ object LocalCache {
deleteWraps(deletedEvent)
}
deleteNote.clearFlow()
deleteNote.clearLive()
notes.remove(deleteNote.idHex)
}
@@ -1339,7 +1405,10 @@ object LocalCache {
if (noteEvent is WrappedEvent) {
deleteWraps(noteEvent)
}
it.clearFlow()
it.clearLive()
}
notes.remove(it.id)
}
}
@@ -2114,7 +2183,7 @@ object LocalCache {
.filter { _, item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
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)
@@ -2178,6 +2247,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())
@@ -2187,13 +2258,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 ->
@@ -2302,6 +2391,9 @@ object LocalCache {
}
}
note.clearFlow()
note.clearLive()
notes.remove(note.idHex)
}
@@ -2635,6 +2727,8 @@ object LocalCache {
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)
@@ -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,6 +65,7 @@ class Patient(
var gender: String? = null,
) : Resource(resourceType, id)
@Stable
class HumanName(
var use: String? = null,
var family: String? = null,
@@ -69,6 +74,7 @@ class HumanName(
fun assembleName(): String = given.joinToString(" ") + " " + family
}
@Stable
class Bundle(
resourceType: String? = null,
id: String = "",
@@ -77,6 +83,7 @@ class Bundle(
var entry: List<Resource> = arrayListOf(),
) : Resource(resourceType, id)
@Stable
class VisionPrescription(
resourceType: String? = null,
id: String = "",
@@ -89,6 +96,7 @@ class VisionPrescription(
var lensSpecification: List<LensSpecification> = arrayListOf(),
) : Resource(resourceType, id)
@Stable
class LensSpecification(
var eye: String? = null,
var sphere: Double? = null,
@@ -105,6 +113,7 @@ class LensSpecification(
var note: String? = null,
)
@Stable
class Prism(
var amount: Double? = null,
var base: String? = null,
@@ -32,9 +32,9 @@ 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.EOSETime
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
@@ -139,8 +139,6 @@ open class Note(
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var associatedNote: Note? = null
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)
@@ -29,8 +29,8 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.EOSETime
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
@@ -331,57 +331,30 @@ class User(
}
}
flowSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
fun isFollowingHashtag(tag: String): Boolean = latestContactList?.isTaggedHash(tag) ?: false
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
fun isFollowingHashtag(tag: String): Boolean {
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 isFollowingCached(userHex: String): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return userHex in it
}
?: false
}
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun cachedFollowingKeySet(): Set<HexKey> = latestContactList?.verifiedFollowKeySet ?: emptySet()
fun cachedFollowingTagSet(): Set<String> = latestContactList?.verifiedFollowTagSet ?: emptySet()
fun cachedFollowingGeohashSet(): Set<HexKey> = latestContactList?.verifiedFollowGeohashSet ?: emptySet()
fun cachedFollowingCommunitiesSet(): Set<HexKey> = latestContactList?.verifiedFollowCommunitySet ?: emptySet()
fun cachedFollowCount(): Int? = latestContactList?.verifiedFollowKeySet?.size
suspend fun cachedFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
@@ -462,12 +435,17 @@ class UserFlowSet(
u: User,
) {
// Observers line up here.
val metadata = UserBundledRefresherFlow(u)
val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean = 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()
}
@@ -488,7 +466,6 @@ class UserLiveSet(
val innerRelayInfo = UserBundledRefresherLiveData(u)
val innerZaps = UserBundledRefresherLiveData(u)
val innerBookmarks = UserBundledRefresherLiveData(u)
val innerGallery = UserBundledRefresherLiveData(u)
val innerStatuses = UserBundledRefresherLiveData(u)
// UI Observers line up here.
@@ -501,7 +478,6 @@ class UserLiveSet(
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map { it }
val gallery = innerGallery.map { it }
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged()
@@ -520,7 +496,6 @@ class UserLiveSet(
relayInfo.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers() ||
gallery.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
@@ -536,7 +511,6 @@ class UserLiveSet(
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerGallery.destroy()
innerStatuses.destroy()
}
}
@@ -0,0 +1,89 @@
/**
* 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.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.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
import java.util.Base64
@Stable
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val matcher = base64contentPattern.matcher(data.toString())
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> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
return if (base64contentPattern.matcher(data.toString()).find()) {
return Base64Fetcher(options, data)
} else {
null
}
}
}
}
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}
@@ -26,21 +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)
fun getCached(content: String): RichTextViewerState? = richTextCache[content]
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 {
val cached = richTextCache[content]
val key = hashCodeCache(content, tags, callbackUri)
val cached = richTextCache[key]
return if (cached != null) {
cached
} else {
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
richTextCache.put(content, newUrls)
richTextCache.put(key, newUrls)
newUrls
}
}
@@ -23,14 +23,22 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.JsonNode
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
@@ -42,16 +50,26 @@ data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: JsonNode,
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<CashuToken>>(20)
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
fun cached(token: String): GenericLoadable<CashuToken> = cashuCache[token] ?: GenericLoadable.Loading()
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
fun parse(token: String): GenericLoadable<CashuToken> {
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
checkNotInMainThread()
val newCachuData = CashuProcessor().parse(token)
cashuCache.put(token, newCachuData)
@@ -62,25 +80,138 @@ object CachedCashuProcessor {
}
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
@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().readTree(String(Base64.getDecoder().decode(base64token)))
val token = cashu.get("token").get(0)
val proofs = token.get("proofs")
val mint = token.get("mint").asText()
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.get("amount").asLong()
if (cashu.token == null) {
return GenericLoadable.Error("No token found")
}
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
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<CashuToken>("Could not parse this cashu token")
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")
}
}
@@ -209,7 +340,21 @@ class CashuProcessor {
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("proofs", token.proofs)
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()
@@ -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)
}
}
}
@@ -26,11 +26,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -68,6 +68,7 @@ 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 : AmethystNostrDataSource("AccountData") {
@@ -81,7 +82,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
@@ -92,7 +93,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
@@ -103,7 +104,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
StatusEvent.KIND,
@@ -124,7 +125,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
@@ -146,7 +147,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
@@ -157,13 +158,13 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
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,
),
)
@@ -172,24 +173,38 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
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.connectToRelays.value.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 =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -214,7 +229,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
val since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
@@ -222,7 +237,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
GitReplyEvent.KIND,
@@ -244,7 +259,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
@@ -263,7 +278,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
if (hasLoadedTheBasics[account.userProfile()] != null) {
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultNotificationFollowList.value,
account.settings.defaultNotificationFollowList.value,
relayUrl,
time,
)
@@ -284,99 +299,121 @@ object NostrAccountDataSource : AmethystNostrDataSource("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 PrivateOutboxRelayListEvent -> {
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)
}
}
}
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 || event.createdAt > noteEvent.createdAt()) {
event.privateTags(account.signer) {
LocalCache.justConsume(event, relay)
}
}
}
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)
val noteEvent = note?.event as? GiftWrapEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
noteEvent.cachedGift(account.signer) {
LocalCache.justConsume(noteEvent, relay)
this.consume(it, relay)
}
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
LocalCache.consume(event, relay)
}
} else {
// new event
event.cachedGift(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)
val noteEvent = note?.event as? SealedGossipEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
// adds the relay to seal and inner chat
noteEvent.cachedGossip(account.signer) {
LocalCache.consume(noteEvent, relay)
LocalCache.justConsume(it, relay)
}
}
} else {
// new event
event.cachedGossip(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) {
event.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
}
}
// 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)
val noteEvent = note?.event as? GiftWrapEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
LocalCache.justConsume(noteEvent, 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)
val noteEvent = note?.event as? SealedGossipEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
LocalCache.justConsume(noteEvent, 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) {
event.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
}
}
else -> {
LocalCache.justConsume(event, relay)
}
}
else -> {
LocalCache.justConsume(event, relay)
}
}
}
@@ -394,16 +431,30 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
}
private fun markInnerAsSeenOnRelay(
noteEvent: EventInterface,
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, relay) }
} else if (noteEvent is SealedGossipEvent) {
noteEvent.cachedGossip(account.signer) { rumor ->
markInnerAsSeenOnRelay(rumor, 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)
}
}
}
}
@@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -58,7 +58,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -71,7 +71,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -86,7 +86,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200,
@@ -96,7 +96,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200,
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -50,7 +50,7 @@ object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = myPeer.users.toList(),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
@@ -73,7 +73,7 @@ object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to myPeer.users.map { it }),
@@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -41,7 +41,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
@@ -56,7 +56,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -71,7 +71,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -91,7 +91,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingEvents.toList(),
since =
@@ -113,7 +113,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it)),
limit = 1,
@@ -131,7 +131,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOf(it)),
since =
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -35,15 +35,19 @@ object NostrCommunityDataSource : AmethystNostrDataSource("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 =
Filter(
authors =
community
.moderators()
.map { it.key }
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
SincePerRelayFilter(
authors = authors,
tags =
mapOf(
"a" to listOf(myCommunityToWatch.address.toTag()),
@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -66,31 +67,30 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.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 =
Filter(
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,
),
),
@@ -98,7 +98,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
@@ -111,7 +111,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -120,7 +120,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
@@ -133,7 +133,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -146,14 +146,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 300,
tags = mapOf("k" to kTags),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
@@ -164,19 +164,22 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
account.liveDiscoveryFollowLists.value
?.users
?.toList()
?.ifEmpty { null }
val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
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,
),
),
@@ -184,14 +187,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
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,
),
)
@@ -200,24 +203,21 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("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 =
Filter(
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,
),
),
@@ -225,14 +225,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
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,
),
)
@@ -243,22 +243,19 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createCommunitiesFilter(): TypedFilter {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
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,
),
)
@@ -275,7 +272,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
@@ -288,7 +285,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -305,7 +302,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
@@ -318,7 +315,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -335,7 +332,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
@@ -349,7 +346,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -366,7 +363,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
@@ -380,7 +377,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -397,7 +394,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
@@ -410,7 +407,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -427,7 +424,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
@@ -440,7 +437,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -450,7 +447,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultDiscoveryFollowList.value,
account.settings.defaultDiscoveryFollowList.value,
relayUrl,
time,
)
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -43,7 +43,7 @@ object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
tags =
mapOf(
"g" to
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -43,7 +43,7 @@ object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
tags =
mapOf(
"t" to
@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -45,7 +46,6 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
lateinit var account: Account
@@ -54,35 +54,45 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
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(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -99,36 +109,35 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
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.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
val follows = account.liveHomeListAuthorsPerRelay.value
return if (followSet != null) {
return if (!follows.isNullOrEmpty()) {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
),
authors = followSet,
authors = follows,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -145,7 +154,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -170,7 +179,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -184,7 +193,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -209,7 +218,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -223,7 +232,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -244,7 +253,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -254,7 +263,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultHomeFollowList.value,
account.settings.defaultHomeFollowList.value,
relayUrl,
time,
)
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.signers.NostrSigner
@@ -39,7 +39,7 @@ class NostrLnZapPaymentResponseDataSource(
return TypedFilter(
types = feedTypes,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(fromServiceHex),
tags =
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -95,7 +95,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, aTag.kind),
authors = listOfNotNull(aTag.pubKeyHex),
// just to be sure
@@ -110,7 +110,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = listOfNotNull(hexToWatch),
),
),
@@ -118,7 +118,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
// just to be sure
@@ -135,7 +135,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 1000,
@@ -144,7 +144,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -165,7 +165,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
@@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -44,7 +44,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to reactionsToWatch),
),
@@ -65,7 +65,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = interestedEvents.toList(),
),
@@ -88,7 +88,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
@@ -23,10 +23,10 @@ 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.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -42,6 +42,7 @@ 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>()
@@ -67,7 +68,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -89,7 +90,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
@@ -117,7 +118,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 5,
@@ -127,7 +128,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
@@ -150,7 +151,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -173,12 +174,13 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
@@ -200,7 +202,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(TextNoteEvent.KIND),
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
@@ -232,7 +234,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = interestedEvents.toList(),
),
),
@@ -319,6 +321,9 @@ fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> =
.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
@@ -327,6 +332,9 @@ fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> =
.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>()
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -45,7 +45,7 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = firstTimers,
),
@@ -64,26 +64,31 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
.map { group ->
val groupIds = group.map { it.pubkeyHex }
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
),
),
)
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()
}
@@ -92,13 +97,11 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("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
}
}
}
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
@@ -45,7 +45,7 @@ object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = eventsToLoad.toList(),
),
)
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -42,6 +42,8 @@ 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 : AmethystNostrDataSource("UserProfileFeed") {
@@ -56,7 +58,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -69,7 +71,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -89,12 +91,29 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("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 =
Filter(
SincePerRelayFilter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 200,
@@ -107,7 +126,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -120,7 +139,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
@@ -132,7 +151,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BadgeProfilesEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -145,7 +164,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
authors = listOf(it.pubkeyHex),
@@ -159,7 +178,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ProfileGalleryEntryEvent.KIND),
authors = listOf(it.pubkeyHex),
@@ -173,7 +192,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BadgeAwardEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20,
@@ -188,6 +207,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createUserPostsFilter2(),
createProfileGalleryFilter(),
createFollowFilter(),
createFollowersFilter(),
@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
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
@@ -64,15 +65,12 @@ object NostrVideoDataSource : AmethystNostrDataSource("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 =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
@@ -80,7 +78,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
)
@@ -97,7 +95,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
@@ -111,7 +109,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
)
@@ -128,7 +126,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
@@ -142,7 +140,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
)
@@ -152,7 +150,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultStoriesFollowList.value,
account.settings.defaultStoriesFollowList.value,
relayUrl,
time,
)
@@ -81,13 +81,15 @@ class ZapPaymentHandler(
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
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
}
@@ -97,13 +99,15 @@ class ZapPaymentHandler(
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
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
}
@@ -26,23 +26,23 @@ import android.util.Log
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.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.encoders.toHexKey
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
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 kotlinx.collections.immutable.persistentSetOf
import java.math.BigDecimal
class EventNotificationConsumer(
@@ -73,27 +73,31 @@ class EventNotificationConsumer(
private suspend fun consumeIfMatchesAccount(
pushWrappedEvent: GiftWrapEvent,
account: Account,
account: AccountSettings,
) {
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
// 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.unwrap(signer) { notificationEvent ->
val consumed = LocalCache.hasConsumed(notificationEvent)
val verified = LocalCache.justVerify(notificationEvent)
Log.d("EventNotificationConsumer", "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${account.userProfile().toBestDisplayName()} consumed= $consumed && verified= $verified")
Log.d("EventNotificationConsumer", "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified")
if (!consumed && verified) {
Log.d("EventNotificationConsumer", "New Notification was verified")
unwrapAndConsume(notificationEvent, account) { innerEvent ->
unwrapAndConsume(notificationEvent, signer) { innerEvent ->
Log.d("EventNotificationConsumer", "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
if (!consumed) {
if (innerEvent is PrivateDmEvent) {
Log.d("EventNotificationConsumer", "New Nip-04 DM to Notify")
notify(innerEvent, account)
notify(innerEvent, signer, account)
} else if (innerEvent is LnZapEvent) {
Log.d("EventNotificationConsumer", "New Zap to Notify")
notify(innerEvent, account)
notify(innerEvent, signer, account)
} else if (innerEvent is ChatMessageEvent) {
Log.d("EventNotificationConsumer", "New ChatMessage to Notify")
notify(innerEvent, account)
notify(innerEvent, signer, account)
}
}
}
@@ -103,7 +107,7 @@ class EventNotificationConsumer(
private fun unwrapAndConsume(
event: Event,
account: Account,
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
if (!LocalCache.justVerify(event)) return
@@ -111,15 +115,19 @@ class EventNotificationConsumer(
when (event) {
is GiftWrapEvent -> {
event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) }
event.unwrap(signer) {
unwrapAndConsume(it, signer, onReady)
LocalCache.justConsume(event, null)
}
}
is SealedGossipEvent -> {
event.cachedGossip(account.signer) {
event.unseal(signer) {
if (!LocalCache.hasConsumed(it)) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
}
LocalCache.justConsume(event, null)
}
}
else -> {
@@ -131,25 +139,25 @@ class EventNotificationConsumer(
private fun notify(
event: ChatMessageEvent,
acc: Account,
signer: NostrSigner,
acc: AccountSettings,
) {
if (
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
// old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
event.pubKey != signer.pubKey
) { // from the user
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
val chatRoom = event.chatroomKey(signer.pubKey)
val followingKeySet = acc.followingKeySet()
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
val isKnownRoom =
(
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
) &&
!acc.isAllHidden(chatRoom.users)
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
myUser.hasSentMessagesTo(chatRoom)
)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
@@ -172,32 +180,27 @@ class EventNotificationConsumer(
private fun notify(
event: PrivateDmEvent,
acc: Account,
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 (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
if (signer.pubKey == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
val knownChatrooms =
acc
.userProfile()
.privateChatrooms
.keys
.filter {
(
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) &&
!acc.isAllHidden(it.users)
}.toSet()
val chatRoom = event.chatroomKey(signer.pubKey)
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
acc.decryptContent(note) { content ->
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()
@@ -209,9 +212,44 @@ class EventNotificationConsumer(
}
}
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,
acc: Account,
signer: NostrSigner,
acc: AccountSettings,
) {
Log.d("EventNotificationConsumer", "Notify Start ${event.toNostrUri()}")
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
@@ -233,20 +271,20 @@ class EventNotificationConsumer(
Log.d("EventNotificationConsumer", "Notify Amount Bigger than 10")
if (event.isTaggedUser(acc.userProfile().pubkeyHex)) {
if (event.isTaggedUser(signer.pubKey)) {
val amount = showAmount(event.amount)
Log.d("EventNotificationConsumer", "Notify Amount $amount")
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
acc.decryptZapContentAuthor(noteZapRequest) {
decryptZapContentAuthor(noteZapRequest, signer) {
Log.d("EventNotificationConsumer", "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) {
acc.decryptContent(noteZapped) {
decryptContent(noteZapped, signer) {
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Note")
val zappedContent = it.split("\n").get(0)
@@ -270,7 +308,7 @@ class EventNotificationConsumer(
zappedContent,
)
}
val userPicture = senderInfo?.first?.profilePicture()
val userPicture = senderInfo.first.profilePicture()
val noteUri = "nostr:Notifications"
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
@@ -24,11 +24,8 @@ 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.model.LocalCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -55,7 +52,7 @@ class RegisterAccounts(
private suspend fun signAllAuths(
notificationToken: String,
remainingTos: List<Pair<Account, String>>,
remainingTos: List<Pair<AccountSettings, List<String>>>,
output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
@@ -71,7 +68,10 @@ class RegisterAccounts(
val result =
withTimeoutOrNull(10000) {
suspendCancellableCoroutine { continuation ->
accountRelayPair.first.createAuthEvent(accountRelayPair.second, notificationToken) { result ->
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)
}
}
@@ -100,44 +100,34 @@ class RegisterAccounts(
) {
val readyToSend =
accounts
.map {
.mapNotNull {
Log.d(tag, "Register Account ${it.npub}")
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val nip65Read =
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex),
)?.event as? AdvertisedRelayListEvent
)?.readRelays() ?: acc.backupNIP65RelayList?.readRelays() ?: emptyList<String>()
val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList()
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP65 Reads ${nip65Read.joinToString(", ")}")
Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}")
val nip17Read =
(
LocalCache
.getAddressableNoteIfExists(
ChatMessageRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex),
)?.event as? ChatMessageRelayListEvent
)?.relays() ?: acc.backupDMRelayList?.relays() ?: emptyList<String>()
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList<String>()
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP17 Reads ${nip17Read.joinToString(", ")}")
Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}")
val kind3Relays = (acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays())
val readKind3Relays = kind3Relays?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
(nip65Read + nip17Read + readKind3Relays).map {
Pair(acc, it)
val relays = (nip65Read + nip17Read + readKind3Relays)
if (relays.isNotEmpty()) {
Pair(acc, relays)
} else {
null
}
} else {
emptyList<Pair<Account, String>>()
null
}
}.flatten()
}
val listOfAuthEvents = mutableListOf<RelayAuthEvent>()
signAllAuths(
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class EOSERelayList(
var relayList: Map<String, EOSETime> = emptyMap(),
@@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
@@ -42,10 +41,9 @@ fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
LaunchedEffect(key1 = displayFeatures, key2 = windowSizeClass) {
LaunchedEffect(sharedPreferencesViewModel, displayFeatures, windowSizeClass) {
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
@@ -57,13 +55,9 @@ fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
}
@Composable
fun AppScreen(
sharedPreferencesViewModel: SharedPreferencesViewModel,
serviceManager: ServiceManager,
) {
fun AppScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) {
AmethystTheme(sharedPreferencesViewModel) {
val accountStateViewModel: AccountStateViewModel = viewModel()
accountStateViewModel.serviceManager = serviceManager
LaunchedEffect(key1 = Unit) {
accountStateViewModel.tryLoginExistingAccountAsync()
@@ -30,12 +30,13 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
@@ -66,19 +67,18 @@ class MainActivity : AppCompatActivity() {
val isOnMobileDataState = mutableStateOf(false)
private val isOnWifiDataState = mutableStateOf(false)
// Service Manager is only active when the activity is active.
val serviceManager = ServiceManager()
private var shouldPauseService = true
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
Log.d("Lifetime Event", "MainActivity.onCreate")
setContent {
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel, serviceManager = serviceManager)
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel)
}
}
@@ -103,7 +103,7 @@ class MainActivity : AppCompatActivity() {
// Keep connection alive if it's calling the signer app
Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService")
if (shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) { serviceManager.justStart() }
GlobalScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.justStart() }
}
GlobalScope.launch(Dispatchers.IO) {
@@ -127,7 +127,7 @@ class MainActivity : AppCompatActivity() {
GlobalScope.launch(Dispatchers.IO) {
LanguageTranslatorService.clear()
}
serviceManager.cleanObservers()
Amethyst.instance.serviceManager.cleanObservers()
// if (BuildConfig.DEBUG) {
GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) }
@@ -135,7 +135,7 @@ class MainActivity : AppCompatActivity() {
Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService")
if (shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) { serviceManager.pauseForGood() }
GlobalScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.pauseForGood() }
}
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
@@ -173,18 +173,6 @@ class MainActivity : AppCompatActivity() {
super.onDestroy()
}
/**
* 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() }
}
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean {
val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
@@ -226,7 +214,7 @@ class MainActivity : AppCompatActivity() {
Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService")
if (shouldPauseService && lastNetwork != null && lastNetwork != network) {
GlobalScope.launch(Dispatchers.IO) { serviceManager.forceRestart() }
GlobalScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.forceRestart() }
}
lastNetwork = network
@@ -245,7 +233,7 @@ class MainActivity : AppCompatActivity() {
"onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}",
)
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
serviceManager.forceRestart()
Amethyst.instance.serviceManager.forceRestart()
}
}
}
@@ -49,14 +49,26 @@ fun stringRes(id: Int): String = resourceCache.get(id) ?: stringResource(id).als
@Composable
fun stringRes(
id: Int,
vararg args: Any,
vararg args: String,
): String =
String
.format(
LocalConfiguration.current.locales.get(0),
resourceCache.get(id) ?: stringResource(id),
resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) },
*args,
).also { resourceCache.put(id, it) }
)
@Composable
fun stringRes(
id: Int,
vararg args: Int?,
): String =
String
.format(
LocalConfiguration.current.locales.get(0),
resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) },
*args,
)
fun stringRes(
ctx: Context,
@@ -66,7 +78,22 @@ fun stringRes(
fun stringRes(
ctx: Context,
id: Int,
vararg args: Any?,
vararg args: String?,
): String {
val res = ctx.resources
return String
.format(
res.configuration.locales.get(0),
resourceCache.get(id) ?: res.getString(id).also { resourceCache.put(id, it) },
*args,
)
}
fun stringRes(
ctx: Context,
id: Int,
vararg args: Int?,
): String {
val res = ctx.resources
@@ -109,10 +109,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -135,12 +133,6 @@ fun EditPostView(
LaunchedEffect(Unit) {
postViewModel.load(edit, versionLookingAt, accountViewModel)
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
}
}
}
DisposableEffect(Unit) {
@@ -266,11 +258,11 @@ fun EditPostView(
Row(Modifier.heightIn(max = 200.dp)) {
NoteCompose(
baseNote = it,
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
quotesLeft = 1,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = false,
makeItShort = true,
quotesLeft = 1,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -337,15 +329,15 @@ fun EditPostView(
) {
ImageVideoDescription(
url,
accountViewModel.account.defaultFileServer,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent ->
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
postViewModel.upload(url, alt, sensitiveContent, false, server, accountViewModel::toast, context)
if (!server.isNip95) {
accountViewModel.account.changeDefaultFileServer(server.server)
accountViewModel.account.settings.changeDefaultFileServer(server.server)
}
},
onCancel = { postViewModel.contentToAddUrl = null },
onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } },
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
accountViewModel = accountViewModel,
)
}
@@ -363,7 +355,7 @@ fun EditPostView(
InvoiceRequest(
lud16,
user.pubkeyHex,
accountViewModel.account,
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
@@ -31,6 +31,7 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
@@ -42,14 +43,13 @@ import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@Stable
@@ -68,8 +68,6 @@ open class EditPostViewModel : ViewModel() {
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val imageUploadingError =
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
@@ -152,6 +150,7 @@ open class EditPostViewModel : ViewModel() {
sensitiveContent: Boolean,
isPrivate: Boolean = false,
server: ServerOption,
onError: (String, String) -> Unit,
context: Context,
) {
isUploadingImage = true
@@ -169,7 +168,16 @@ open class EditPostViewModel : ViewModel() {
onReady = { fileUri, contentType, size ->
if (server.isNip95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
createNIP95Record(
it.readBytes(),
contentType,
alt,
sensitiveContent,
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context,
)
}
} else {
viewModelScope.launch(Dispatchers.IO) {
@@ -193,6 +201,10 @@ open class EditPostViewModel : ViewModel() {
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -202,16 +214,14 @@ open class EditPostViewModel : ViewModel() {
e,
)
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload: ${e.message}")
}
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
},
onError = {
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit(it) }
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
},
)
}
@@ -306,6 +316,8 @@ open class EditPostViewModel : ViewModel() {
localContentType: String?,
alt: String?,
sensitiveContent: Boolean,
onError: (String) -> Unit = {},
context: Context,
) {
// Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
@@ -334,7 +346,7 @@ open class EditPostViewModel : ViewModel() {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
return
}
@@ -353,7 +365,7 @@ open class EditPostViewModel : ViewModel() {
},
onError = {
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
onError(stringRes(context, R.string.could_not_prepare_header, it))
},
)
}
@@ -363,10 +375,12 @@ open class EditPostViewModel : ViewModel() {
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
onError: (String) -> Unit = {},
context: Context,
) {
if (bytes.size > 80000) {
viewModelScope.launch {
imageUploadingError.emit("Media is too big for NIP-95")
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
isUploadingImage = false
}
return
@@ -393,7 +407,7 @@ open class EditPostViewModel : ViewModel() {
},
onError = {
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
onError(stringRes(context, R.string.could_not_prepare_header, it))
},
)
}
@@ -33,11 +33,41 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import java.io.PrintWriter
import java.io.StringWriter
@Composable
fun InformationDialog(
title: String,
textContent: String?,
throwable: Throwable,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onDismiss: () -> Unit,
) {
val stack = stringRes(id = R.string.stack)
val str =
remember(throwable) {
val writer = StringWriter()
textContent?.let {
writer.append(it)
writer.append("\n\n")
}
writer.append(stack)
writer.append("\n")
throwable.printStackTrace(PrintWriter(writer))
writer.toString()
}
InformationDialog(title = title, textContent = str, buttonColors, onDismiss)
}
@Composable
fun InformationDialog(
@@ -49,7 +79,11 @@ fun InformationDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { SelectionContainer { Text(textContent) } },
text = {
SelectionContainer {
Text(textContent)
}
},
confirmButton = {
Button(
onClick = onDismiss,
@@ -50,7 +50,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -387,8 +386,6 @@ private fun RenderChannel(
loadRobohash: Boolean,
onClick: () -> Unit,
) {
val hasNewMessages = remember { mutableStateOf(false) }
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
@@ -400,7 +397,7 @@ private fun RenderChannel(
},
channelLastTime = null,
channelLastContent = item.summary(),
hasNewMessages,
hasNewMessages = false,
onClick = onClick,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
@@ -64,24 +64,22 @@ open class NewMediaModel : ViewModel() {
var uploadingDescription = mutableStateOf<String?>(null)
var onceUploaded: () -> Unit = {}
var onError: (String) -> Unit = {}
open fun load(
account: Account,
uri: Uri,
contentType: String?,
onError: (String) -> Unit,
) {
this.account = account
this.galleryUri = uri
this.mediaType = contentType
this.selectedServer = ServerOption(defaultServer(), false)
this.onError = onError
}
fun upload(
context: Context,
relayList: List<RelaySetupInfo>? = null,
onError: (String) -> Unit = {},
) {
isUploadingImage = true
@@ -110,6 +108,7 @@ open class NewMediaModel : ViewModel() {
alt,
sensitiveContent,
relayList = relayList,
onError = onError,
context,
)
}
@@ -148,6 +147,7 @@ open class NewMediaModel : ViewModel() {
alt = alt,
sensitiveContent = sensitiveContent,
relayList = relayList,
onError = onError,
context,
)
} catch (e: Exception) {
@@ -189,6 +189,7 @@ open class NewMediaModel : ViewModel() {
alt: String,
sensitiveContent: Boolean,
relayList: List<RelaySetupInfo>? = null,
onError: (String) -> Unit = {},
context: Context,
) {
uploadingPercentage.value = 0.40f
@@ -282,11 +283,12 @@ open class NewMediaModel : ViewModel() {
alt: String,
sensitiveContent: Boolean,
relayList: List<RelaySetupInfo>? = null,
onError: (String) -> Unit = {},
context: Context,
) {
if (bytes.size > 80000) {
viewModelScope.launch {
onError("Media is too big for NIP-95")
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
@@ -331,7 +333,7 @@ open class NewMediaModel : ViewModel() {
fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
fun defaultServer() = account?.settings?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded
@@ -94,9 +94,7 @@ fun NewMediaView(
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType) {
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
}
postViewModel.load(account, uri, mediaType)
}
var showRelaysDialog by remember { mutableStateOf(false) }
@@ -161,10 +159,12 @@ fun NewMediaView(
PostButton(
onPost = {
onClose()
postViewModel.upload(context, relayList)
postViewModel.upload(context, relayList) {
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
}
postViewModel.selectedServer?.let {
if (!it.isNip95) {
account.changeDefaultFileServer(it.server)
account.settings.changeDefaultFileServer(it.server)
}
}
},
@@ -290,7 +290,7 @@ fun ImageVideoPost(
label = stringRes(id = R.string.file_server),
placeholder =
fileServers
.firstOrNull { it.server == accountViewModel.account.defaultFileServer }
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer }
?.server
?.name
?: fileServers[0].server.name,
@@ -145,10 +145,10 @@ import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -180,7 +180,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.Math.round
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@@ -220,10 +219,6 @@ fun NewPostView(
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft)
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
}
}
}
@@ -356,11 +351,11 @@ fun NewPostView(
Row(Modifier.heightIn(max = 200.dp)) {
NoteCompose(
baseNote = it,
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
quotesLeft = 1,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = false,
makeItShort = true,
quotesLeft = 1,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -496,15 +491,15 @@ fun NewPostView(
) {
ImageVideoDescription(
url,
accountViewModel.account.defaultFileServer,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent ->
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
postViewModel.upload(url, alt, sensitiveContent, false, server, accountViewModel::toast, context)
if (!server.isNip95) {
accountViewModel.account.changeDefaultFileServer(server.server)
accountViewModel.account.settings.changeDefaultFileServer(server.server)
}
},
onCancel = { postViewModel.contentToAddUrl = null },
onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } },
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
accountViewModel = accountViewModel,
)
}
@@ -520,7 +515,7 @@ fun NewPostView(
InvoiceRequest(
lud16,
accountViewModel.account.userProfile().pubkeyHex,
accountViewModel.account,
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
@@ -1616,7 +1611,7 @@ fun ImageVideoDescription(
defaultServer: Nip96MediaServers.ServerName,
onAdd: (String, ServerOption, Boolean) -> Unit,
onCancel: () -> Unit,
onError: (String) -> Unit,
onError: (Int) -> Unit,
accountViewModel: AccountViewModel,
) {
val resolver = LocalContext.current.contentResolver
@@ -1753,7 +1748,7 @@ fun ImageVideoDescription(
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Unable to load thumbnail")
onError(R.string.unable_to_load_thumbnail)
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
@@ -1783,7 +1778,7 @@ fun ImageVideoDescription(
label = stringRes(id = R.string.file_server),
placeholder =
fileServers
.firstOrNull { it.server == accountViewModel.account.defaultFileServer }
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer }
?.server
?.name
?: fileServers[0].server.name,
@@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
@@ -48,6 +49,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
@@ -67,15 +69,15 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup
import com.vitorpamplona.quartz.events.findURLs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -109,8 +111,6 @@ open class NewPostViewModel : ViewModel() {
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val imageUploadingError =
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
@@ -683,6 +683,74 @@ open class NewPostViewModel : ViewModel() {
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (originalNote?.event is TorrentCommentEvent) {
val originalNoteEvent = originalNote?.event as TorrentCommentEvent
// adds markers
val rootId =
originalNoteEvent.torrent() // if it has a marker as root
?: originalNote
?.replyTo
?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }
?.idHex // if it has loaded events with zero replies in the reply list
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
?: originalNote?.idHex
if (rootId != null) {
// There must be a torrent ID
val replyId = originalNote?.idHex
val replyToSet =
if (forkedFromNote != null) {
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
} else {
tagger.eTags
}
account?.sendTorrentComment(
message = tagger.message,
replyTo = replyToSet,
mentions = tagger.pTags,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions,
forkedFrom = forkedFromNote?.event as? Event,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
} else if (originalNote?.event is TorrentEvent) {
val originalNoteEvent = originalNote?.event as TorrentEvent
// adds markers
val rootId = originalNoteEvent.id
val replyToSet =
if (forkedFromNote != null) {
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
} else {
tagger.eTags
}
account?.sendTorrentComment(
message = tagger.message,
replyTo = replyToSet,
mentions = tagger.pTags,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = null,
root = rootId,
directMentions = tagger.directMentions,
forkedFrom = forkedFromNote?.event as? Event,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
if (wantsPoll) {
account?.sendPoll(
@@ -768,6 +836,7 @@ open class NewPostViewModel : ViewModel() {
sensitiveContent: Boolean,
isPrivate: Boolean = false,
server: ServerOption,
onError: (title: String, message: String) -> Unit,
context: Context,
) {
isUploadingImage = true
@@ -785,7 +854,16 @@ open class NewPostViewModel : ViewModel() {
onReady = { fileUri, contentType, size ->
if (server.isNip95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
createNIP95Record(
it.readBytes(),
contentType,
alt,
sensitiveContent,
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context,
)
}
} else {
viewModelScope.launch(Dispatchers.IO) {
@@ -809,6 +887,10 @@ open class NewPostViewModel : ViewModel() {
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -818,16 +900,14 @@ open class NewPostViewModel : ViewModel() {
e,
)
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload: ${e.message}")
}
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
},
onError = {
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit(it) }
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
},
)
}
@@ -1064,6 +1144,8 @@ open class NewPostViewModel : ViewModel() {
localContentType: String?,
alt: String?,
sensitiveContent: Boolean,
onError: (message: String) -> Unit,
context: Context,
) {
// Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
@@ -1092,7 +1174,7 @@ open class NewPostViewModel : ViewModel() {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
return
}
@@ -1112,7 +1194,7 @@ open class NewPostViewModel : ViewModel() {
},
onError = {
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
onError(stringRes(context, R.string.could_not_prepare_header, it))
},
)
}
@@ -1126,10 +1208,12 @@ open class NewPostViewModel : ViewModel() {
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
onError: (message: String) -> Unit,
context: Context,
) {
if (bytes.size > 80000) {
viewModelScope.launch {
imageUploadingError.emit("Media is too big for NIP-95")
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
isUploadingImage = false
}
return
@@ -1157,7 +1241,7 @@ open class NewPostViewModel : ViewModel() {
},
onError = {
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
onError(stringRes(context, R.string.could_not_prepare_header, it))
},
)
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -46,29 +45,20 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun NewUserMetadataView(
onClose: () -> Unit,
account: Account,
accountViewModel: AccountViewModel,
) {
val postViewModel: NewUserMetadataViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(Unit) {
postViewModel.load(account)
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
}
}
postViewModel.load(accountViewModel.account)
}
Dialog(
@@ -164,7 +154,7 @@ fun NewUserMetadataView(
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp),
) {
postViewModel.uploadForPicture(it, context)
postViewModel.uploadForPicture(it, context, onError = accountViewModel::toast)
}
},
singleLine = true,
@@ -189,7 +179,7 @@ fun NewUserMetadataView(
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp),
) {
postViewModel.uploadForBanner(it, context)
postViewModel.uploadForBanner(it, context, onError = accountViewModel::toast)
}
},
singleLine = true,
@@ -27,15 +27,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.GitHubIdentity
import com.vitorpamplona.quartz.events.MastodonIdentity
import com.vitorpamplona.quartz.events.TwitterIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@@ -60,8 +60,6 @@ class NewUserMetadataViewModel : ViewModel() {
var isUploadingImageForPicture by mutableStateOf(false)
var isUploadingImageForBanner by mutableStateOf(false)
val imageUploadingError =
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
fun load(account: Account) {
this.account = account
@@ -130,6 +128,7 @@ class NewUserMetadataViewModel : ViewModel() {
fun uploadForPicture(
uri: Uri,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
@@ -137,6 +136,7 @@ class NewUserMetadataViewModel : ViewModel() {
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = it },
onError = onError,
)
}
}
@@ -144,6 +144,7 @@ class NewUserMetadataViewModel : ViewModel() {
fun uploadForBanner(
uri: Uri,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
@@ -151,6 +152,7 @@ class NewUserMetadataViewModel : ViewModel() {
context,
onUploading = { isUploadingImageForBanner = it },
onUploaded = { banner.value = it },
onError = onError,
)
}
}
@@ -160,6 +162,7 @@ class NewUserMetadataViewModel : ViewModel() {
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
@@ -181,7 +184,7 @@ class NewUserMetadataViewModel : ViewModel() {
size = size,
alt = null,
sensitiveContent = null,
server = account.defaultFileServer,
server = account.settings.defaultFileServer,
contentResolver = contentResolver,
onProgress = {},
context = context,
@@ -194,22 +197,19 @@ class NewUserMetadataViewModel : ViewModel() {
onUploaded(url)
} else {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
},
onError = {
onUploading(false)
viewModelScope.launch { imageUploadingError.emit(it) }
onError(stringRes(context, R.string.error_when_compressing_media), stringRes(context, it))
},
)
}
@@ -55,7 +55,7 @@ data class Kind3RelayProposalSetupInfo(
val feedTypes: Set<FeedType>,
val relayStat: RelayStat,
val paidRelay: Boolean = false,
val users: Set<HexKey>,
val users: List<HexKey>,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
@@ -25,21 +25,20 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
@Composable
@@ -51,25 +50,23 @@ fun BasicRelaySetupInfoClickableRow(
onClick: () -> Unit,
accountViewModel: AccountViewModel,
) {
Column(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
modifier = HalfVertPadding,
) {
Column(Modifier.clickable(onClick = onClick)) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
Spacer(modifier = HalfHorzPadding)
@@ -78,12 +75,12 @@ fun BasicRelaySetupInfoClickableRow(
item,
onClick,
onDelete,
ReactionRowHeightChat.fillMaxWidth(),
ReactionRowHeightChatMaxWidth,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
modifier = ReactionRowHeightChatMaxWidth,
) {
RelayStatusRow(
item = item,
@@ -78,7 +78,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -307,34 +307,32 @@ fun ClickableRelayItem(
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
onClick: () -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
Column(Modifier.clickable(onClick = onClick)) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
Spacer(modifier = HalfHorzPadding)
Column(Modifier.weight(1f)) {
FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth())
FirstLine(item, onClick, onDelete, ReactionRowHeightChatMaxWidth)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
modifier = ReactionRowHeightChatMaxWidth,
) {
ActiveToggles(
item = item,
@@ -347,7 +345,7 @@ fun ClickableRelayItem(
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
modifier = ReactionRowHeightChatMaxWidth,
) {
StatusRow(
item = item,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
@@ -30,7 +31,7 @@ import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
import com.vitorpamplona.quartz.utils.RelayListRecommendationProcessor
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Stable
class Kind3RelayListViewModel : ViewModel() {
private lateinit var account: Account
@@ -96,7 +98,7 @@ class Kind3RelayListViewModel : ViewModel() {
relayFile
.map {
val localInfoFeedTypes =
account.localRelays
account.settings.localRelays
.filter { localRelay -> localRelay.url == it.key }
.firstOrNull()
?.feedTypes
@@ -117,7 +119,7 @@ class Kind3RelayListViewModel : ViewModel() {
.sortedBy { it.relayStat.receivedBytes }
.reversed()
} else {
account.localRelays
account.settings.localRelays
.map {
Kind3BasicRelaySetupInfo(
url = RelayUrlFormatter.normalize(it.url),
@@ -138,7 +140,7 @@ class Kind3RelayListViewModel : ViewModel() {
private fun refreshProposals() {
_proposedRelays.update {
val proposed =
MinimumRelayListProcessor
RelayListRecommendationProcessor
.reliableRelaySetFor(
account.liveKind3Follows.value.users.mapNotNull {
account.getNIP65RelayList(it)
@@ -162,7 +164,7 @@ class Kind3RelayListViewModel : ViewModel() {
write = false,
feedTypes = setOf(FeedType.FOLLOWS),
relayStat = RelayStats.get(it.url),
users = it.users,
users = it.users.sorted(),
)
} else {
null
@@ -22,12 +22,14 @@ package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
@@ -41,18 +43,25 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.note.AddRelayButton
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.RelayStat
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -65,64 +74,54 @@ fun Kind3RelaySetupInfoProposalRow(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
Column(Modifier.clickable(onClick = onClick)) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
Spacer(modifier = HalfHorzPadding)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat.fillMaxWidth()) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.briefInfo.displayUrl,
modifier = Modifier.clickable(onClick = onClick),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(ReactionRowHeightChatMaxWidth, verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.briefInfo.displayUrl,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.paidRelay) {
Icon(
imageVector = Icons.Default.Paid,
null,
modifier =
Modifier
.padding(start = 5.dp, top = 1.dp)
.size(14.dp),
tint = MaterialTheme.colorScheme.allGoodColor,
)
}
}
}
FlowRow(verticalArrangement = Arrangement.Center) {
item.users.forEach {
UserPicture(
userHex = it,
size = Size25dp,
accountViewModel = accountViewModel,
nav = nav,
if (item.paidRelay) {
Icon(
imageVector = Icons.Default.Paid,
null,
modifier =
Modifier
.padding(start = 5.dp, top = 1.dp)
.size(14.dp),
tint = MaterialTheme.colorScheme.allGoodColor,
)
}
}
}
UsedBy(item, accountViewModel, nav)
Column(
Modifier
.padding(start = 10.dp),
@@ -134,3 +133,68 @@ fun Kind3RelaySetupInfoProposalRow(
HorizontalDivider(thickness = DividerThickness)
}
}
@Preview
@Composable
fun UsedByPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
UsedBy(
item =
Kind3RelayProposalSetupInfo(
"wss://nos.lol",
true,
true,
COMMON_FEED_TYPES,
relayStat = RelayStat(),
paidRelay = false,
users = listOf("User1", "User2", "User3", "User4"),
),
accountViewModel = accountViewModel,
) {
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun UsedBy(
item: Kind3RelayProposalSetupInfo,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
FlowRow(verticalArrangement = Arrangement.Center) {
item.users.getOrNull(0)?.let {
UserPicture(
userHex = it,
size = Size25dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
item.users.getOrNull(1)?.let {
UserPicture(
userHex = it,
size = Size25dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
item.users.getOrNull(2)?.let {
UserPicture(
userHex = it,
size = Size25dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (item.users.size > 3) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.height(Size25dp)) {
Text(
text = stringRes(R.string.and_more, item.users.size - 3),
maxLines = 1,
)
}
}
}
}
@@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst.ui.actions.relays
class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? = account.localRelayServers.toList()
override fun getRelayList(): List<String> = account.settings.localRelayServers.toList()
override fun saveRelayList(urlList: List<String>) {
account.updateLocalRelayServers(urlList.toSet())
account.settings.updateLocalRelayServers(urlList.toSet())
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
@@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Stable
class Nip65RelayListViewModel : ViewModel() {
private lateinit var account: Account
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -141,9 +142,8 @@ fun RelayInformationDialog(
Spacer(modifier = DoubleHorzSpacer)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row { Title(relayInfo.name?.trim() ?: "") }
Row { SubtitleContent(relayInfo.description?.trim() ?: "") }
Title(relayInfo.name?.trim() ?: "")
SubtitleContent(relayInfo.description?.trim() ?: "")
}
}
}
@@ -286,20 +286,22 @@ fun RelayInformationDialog(
items(messages) { msg ->
Row {
TranslatableRichTextViewer(
content =
remember {
"${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}"
},
canPreview = false,
quotesLeft = 0,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
backgroundColor = color,
id = msg.hashCode().toString(),
accountViewModel = accountViewModel,
nav = nav,
)
SelectionContainer {
TranslatableRichTextViewer(
content =
remember {
"${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}"
},
canPreview = false,
quotesLeft = 0,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
backgroundColor = color,
id = msg.hashCode().toString(),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
Spacer(modifier = StdVertSpacer)
@@ -21,8 +21,7 @@
package com.vitorpamplona.amethyst.ui.actions.relays
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
@@ -35,6 +34,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -47,7 +47,6 @@ import com.vitorpamplona.amethyst.ui.theme.warningColor
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun RelayStatusRow(
item: BasicRelaySetupInfo,
modifier: Modifier,
@@ -62,15 +61,16 @@ fun RelayStatusRow(
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.read_from_relay,
R.string.read_from_relay_description,
)
},
),
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
accountViewModel.toast(
R.string.read_from_relay,
R.string.read_from_relay_description,
)
},
)
},
tint = MaterialTheme.colorScheme.allGoodColor,
)
@@ -88,15 +88,16 @@ fun RelayStatusRow(
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
accountViewModel.toast(
R.string.write_to_relay,
R.string.write_to_relay_description,
)
},
),
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
accountViewModel.toast(
R.string.write_to_relay,
R.string.write_to_relay_description,
)
},
)
},
tint = MaterialTheme.colorScheme.allGoodColor,
)
@@ -114,15 +115,16 @@ fun RelayStatusRow(
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.errors,
R.string.errors_description,
)
},
),
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
accountViewModel.toast(
R.string.errors,
R.string.errors_description,
)
},
)
},
tint =
if (item.relayStat.errorCounter > 0) {
MaterialTheme.colorScheme.warningColor
@@ -145,23 +147,24 @@ fun RelayStatusRow(
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.spam,
R.string.spam_description,
)
scope.launch {
Toast
.makeText(
context,
stringRes(context, R.string.spam),
Toast.LENGTH_SHORT,
).show()
}
},
),
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
accountViewModel.toast(
R.string.spam,
R.string.spam_description,
)
scope.launch {
Toast
.makeText(
context,
stringRes(context, R.string.spam),
Toast.LENGTH_SHORT,
).show()
}
},
)
},
tint =
if (item.relayStat.spamCounter > 0) {
MaterialTheme.colorScheme.warningColor
@@ -40,7 +40,6 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
@@ -67,7 +66,6 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
@Composable
fun AudioWaveformReadOnly(
modifier: Modifier = Modifier,
style: DrawStyle = Fill,
waveformBrush: Brush = SolidColor(Color.White),
progressBrush: Brush = SolidColor(Color.Blue),
waveformAlignment: WaveformAlignment = WaveformAlignment.Center,
@@ -131,7 +129,7 @@ fun AudioWaveformReadOnly(
height = amplitude,
),
cornerRadius = CornerRadius(spikeRadiusState.toPx(), spikeRadiusState.toPx()),
style = style,
style = Fill,
)
drawRect(
brush = progressBrush,
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -31,11 +30,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
@@ -53,14 +49,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.fasterxml.jackson.databind.node.TextNode
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
@@ -69,7 +63,6 @@ import com.vitorpamplona.amethyst.service.CachedCashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.CashuIcon
import com.vitorpamplona.amethyst.ui.note.CopyIcon
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
import com.vitorpamplona.amethyst.ui.note.ZapIcon
@@ -77,14 +70,12 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -108,8 +99,8 @@ fun CashuPreview(
CrossfadeIfEnabled(targetState = cashuData, label = "CashuPreview", accountViewModel = accountViewModel) {
when (it) {
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
is GenericLoadable.Error<CashuToken> ->
is GenericLoadable.Loaded<ImmutableList<CashuToken>> -> CashuPreview(it.loaded, accountViewModel)
is GenericLoadable.Error<ImmutableList<CashuToken>> ->
Text(
text = "$cashutoken ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
@@ -121,10 +112,12 @@ fun CashuPreview(
@Composable
fun CashuPreview(
token: CashuToken,
tokens: ImmutableList<CashuToken>,
accountViewModel: AccountViewModel,
) {
CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast)
tokens.forEach {
CashuPreviewNew(it, accountViewModel::meltCashu, accountViewModel::toast)
}
}
@Composable
@@ -137,14 +130,8 @@ fun CashuPreviewPreview() {
AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) {
Column {
CashuPreview(
token = CashuToken("token", "mint", 32400, TextNode("")),
melt = { token, context, onDone -> },
toast = { title, message -> },
)
CashuPreviewNew(
token = CashuToken("token", "mint", 32400, TextNode("")),
token = CashuToken("token", "mint", 32400, listOf()),
melt = { token, context, onDone -> },
toast = { title, message -> },
)
@@ -152,148 +139,6 @@ fun CashuPreviewPreview() {
}
}
@Composable
fun CashuPreview(
token: CashuToken,
melt: (CashuToken, Context, (String, String) -> Unit) -> Unit,
toast: (String, String) -> Unit,
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(20.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Icon(
imageVector = CustomHashTagIcons.Cashu,
null,
modifier = Size20Modifier,
tint = Color.Unspecified,
)
Text(
text = stringRes(R.string.cashu),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
)
}
HorizontalDivider(thickness = DividerThickness)
Text(
text = "${token.totalAmount} ${stringRes(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
Row(
modifier =
Modifier
.padding(top = 5.dp)
.fillMaxWidth(),
) {
var isRedeeming by remember { mutableStateOf(false) }
Button(
onClick = {
isRedeeming = true
melt(token, context) { title, message ->
toast(title, message)
isRedeeming = false
}
},
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
if (isRedeeming) {
LoadingAnimation()
} else {
ZapIcon(Size20Modifier, tint = Color.White)
}
Spacer(DoubleHorzSpacer)
Text(
stringRes(id = R.string.cashu_redeem_to_zap),
color = Color.White,
fontSize = 16.sp,
)
}
}
Spacer(modifier = StdHorzSpacer)
Button(
onClick = {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(context, intent, null)
} catch (e: Exception) {
if (e is CancellationException) throw e
toast(stringRes(context, R.string.cashu), stringRes(context, R.string.cashu_no_wallet_found))
}
},
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
CashuIcon(Size20Modifier)
Spacer(DoubleHorzSpacer)
Text(
stringRes(id = R.string.cashu_redeem_to_cashu),
color = Color.White,
fontSize = 16.sp,
)
}
Spacer(modifier = StdHorzSpacer)
Button(
onClick = {
// Copying the token to clipboard
clipboardManager.setText(AnnotatedString(token.token))
},
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
CopyIcon(Size20Modifier, Color.White)
Spacer(DoubleHorzSpacer)
Text(stringRes(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp)
}
Spacer(modifier = StdHorzSpacer)
}
}
}
@Composable
fun CashuPreviewNew(
token: CashuToken,
@@ -339,6 +184,12 @@ fun CashuPreviewNew(
fontSize = 20.sp,
modifier = Modifier.padding(top = 5.dp),
)
Text(
text = "Mint: " + token.mint.replace("https://", ""),
fontSize = 7.sp,
color = Color.Gray,
modifier = Modifier.padding(start = 5.dp, bottom = 1.dp),
)
Row(modifier = Modifier.padding(top = 5.dp)) {
var isRedeeming by remember { mutableStateOf(false) }
@@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -59,7 +60,7 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
@@ -80,12 +81,12 @@ fun ClickableRoute(
nav: (String) -> Unit,
) {
when (val entity = nip19.entity) {
is Nip19Bech32.NPub -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NProfile -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.Note -> DisplayEvent(entity.hex, null, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NPub -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NProfile -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.Note -> DisplayEvent(entity.hex, null, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NEmbed -> LoadAndDisplayEvent(entity.event, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NAddress -> DisplayAddress(entity, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NAddress -> DisplayAddress(entity, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NRelay -> {
Text(word)
}
@@ -127,11 +128,16 @@ private fun LoadAndDisplayEvent(
if (it != null) {
DisplayNoteLink(it, event.id, event.kind, additionalChars, accountViewModel, nav)
} else {
val externalLink = event.toNIP19()
val uri = LocalUriHandler.current
CreateClickableText(
clickablePart = remember(event.id) { "@${event.toNIP19()}" },
clickablePart = "@$externalLink",
suffix = additionalChars,
route = remember(event.id) { "Event/${event.id}" },
nav = nav,
maxLines = 1,
onClick = {
runCatching { uri.openUri(njumpLink(externalLink)) }
},
)
}
}
@@ -141,6 +147,7 @@ private fun LoadAndDisplayEvent(
fun DisplayEvent(
hex: HexKey,
kind: Int?,
nip19: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -149,11 +156,16 @@ fun DisplayEvent(
if (it != null) {
DisplayNoteLink(it, hex, kind, additionalChars, accountViewModel, nav)
} else {
val externalLink = njumpLink(nip19)
val uri = LocalUriHandler.current
CreateClickableText(
clickablePart = remember(hex) { "@${hex.toShortenHex()}" },
clickablePart = remember(nip19) { "@$nip19" },
suffix = additionalChars,
route = remember(hex) { "Event/$hex" },
nav = nav,
maxLines = 1,
onClick = {
runCatching { uri.openUri(externalLink) }
},
)
}
}
@@ -218,6 +230,7 @@ private fun DisplayNoteLink(
@Composable
private fun DisplayAddress(
nip19: Nip19Bech32.NAddress,
originalNip19: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -245,21 +258,23 @@ private fun DisplayAddress(
}
if (noteBase == null) {
if (additionalChars != null) {
Text(
remember { "@${nip19.atag}$additionalChars" },
)
} else {
Text(
remember { "@${nip19.atag}" },
)
}
val uri = LocalUriHandler.current
CreateClickableText(
clickablePart = "@$originalNip19",
suffix = additionalChars,
maxLines = 1,
onClick = {
runCatching { uri.openUri(njumpLink(originalNip19)) }
},
)
}
}
@Composable
public fun DisplayUser(
userHex: HexKey,
originalNip19: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -280,15 +295,16 @@ public fun DisplayUser(
userBase?.let { RenderUserAsClickableText(it, additionalChars, nav) }
if (userBase == null) {
if (additionalChars != null) {
Text(
remember { "@${userHex}$additionalChars" },
)
} else {
Text(
remember { "@$userHex" },
)
}
val uri = LocalUriHandler.current
CreateClickableText(
clickablePart = "@$originalNip19",
suffix = additionalChars,
maxLines = 1,
onClick = {
runCatching { uri.openUri(njumpLink(originalNip19)) }
},
)
}
}
@@ -320,6 +336,26 @@ fun CreateClickableText(
fontSize: TextUnit = TextUnit.Unspecified,
route: String,
nav: (String) -> Unit,
) {
CreateClickableText(
clickablePart,
suffix,
maxLines,
overrideColor,
fontWeight,
fontSize,
) { nav(route) }
}
@Composable
fun CreateClickableText(
clickablePart: String,
suffix: String?,
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
onClick: (Int) -> Unit,
) {
val primaryColor = MaterialTheme.colorScheme.primary
val onBackgroundColor = MaterialTheme.colorScheme.onBackground
@@ -351,7 +387,8 @@ fun CreateClickableText(
ClickableText(
text = text,
maxLines = maxLines,
onClick = { nav(route) },
overflow = TextOverflow.Ellipsis,
onClick = onClick,
)
}
@@ -37,7 +37,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -52,24 +51,19 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.LnZapEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun InvoiceRequestCard(
lud16: String,
toUserPubKeyHex: String,
account: Account,
accountViewModel: AccountViewModel,
titleText: String? = null,
buttonText: String? = null,
onSuccess: (String) -> Unit,
@@ -90,7 +84,7 @@ fun InvoiceRequestCard(
InvoiceRequest(
lud16,
toUserPubKeyHex,
account,
accountViewModel,
titleText,
buttonText,
onSuccess,
@@ -105,7 +99,7 @@ fun InvoiceRequestCard(
fun InvoiceRequest(
lud16: String,
toUserPubKeyHex: String,
account: Account,
accountViewModel: AccountViewModel,
titleText: String? = null,
buttonText: String? = null,
onSuccess: (String) -> Unit,
@@ -113,7 +107,6 @@ fun InvoiceRequest(
onError: (String, String) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -186,36 +179,16 @@ fun InvoiceRequest(
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
scope.launch(Dispatchers.IO) {
if (account.defaultZapType == LnZapEvent.ZapType.NONZAP) {
LightningAddressResolver()
.lnAddressInvoice(
lud16,
amount * 1000,
message,
null,
onSuccess = onSuccess,
onError = onError,
onProgress = {},
context = context,
)
} else {
account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType) { zapRequest ->
LocalCache.justConsume(zapRequest, null)
LightningAddressResolver()
.lnAddressInvoice(
lud16,
amount * 1000,
message,
zapRequest.toJson(),
onSuccess = onSuccess,
onError = onError,
onProgress = {},
context = context,
)
}
}
}
accountViewModel.sendSats(
lnaddress = lud16,
milliSats = amount * 1000,
message = message,
toUserPubKeyHex = toUserPubKeyHex,
onSuccess = onSuccess,
onError = onError,
onProgress = {},
context = context,
)
},
shape = QuoteBorder,
colors =
@@ -30,6 +30,7 @@ import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
@@ -44,7 +45,7 @@ class MediaCompressor {
contentType: String?,
applicationContext: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (String) -> Unit,
onError: (Int) -> Unit,
) {
checkNotInMainThread()
@@ -87,7 +88,7 @@ class MediaCompressor {
if (path != null) {
onReady(Uri.fromFile(File(path)), contentType, size)
} else {
onError("Compression Returned null")
onError(R.string.compression_returned_null)
}
}
@@ -100,7 +101,7 @@ class MediaCompressor {
}
override fun onCancelled(index: Int) {
onError("Compression Cancelled")
onError(R.string.compression_cancelled)
}
},
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.util.Base64
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -29,7 +28,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -49,8 +47,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -64,11 +60,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImagePainter
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
@@ -83,12 +74,10 @@ import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HashtagIcon
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -96,23 +85,16 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.withContext
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
@@ -234,26 +216,6 @@ fun RenderRegularPreview2() {
}
}
@Composable
fun mockAccountViewModel(): AccountViewModel {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
sharedPreferencesViewModel.init()
return AccountViewModel(
Account(
// blank keys
keyPair =
KeyPair(
privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"),
pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"),
forcePubKeyCheck = false,
),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
),
sharedPreferencesViewModel.sharedPrefs,
)
}
@Preview
@Composable
fun RenderRegularPreview3() {
@@ -445,47 +407,7 @@ private fun RenderWordWithPreview(
is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ImageFromBase64(word.segmentText)
}
}
@Composable
fun ImageFromBase64(base64String: String) {
val context = LocalContext.current
var imageBytes by remember { mutableStateOf<ByteArray?>(null) }
LaunchedEffect(base64String) {
imageBytes =
withContext(Dispatchers.IO) {
var base64String2 = base64String.removePrefix("data:image/jpeg;base64,")
RichTextParser.imageExtensions.forEach {
base64String2 = base64String2.removePrefix("data:image/$it;base64,")
}
runCatching { Base64.decode(base64String2, Base64.DEFAULT) }.getOrNull()
}
}
if (imageBytes == null) {
BlankNote()
} else {
val request =
ImageRequest
.Builder(context)
.data(imageBytes)
.build()
SubcomposeAsyncImage(
model = request,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
) {
when (painter.state) {
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent()
}
else -> BlankNote()
}
}
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
}
}
@@ -570,11 +492,11 @@ fun DisplayFullNote(
) {
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = MaterialTheme.colorScheme.innerPostModifier,
parentBackgroundColor = backgroundColor,
quotesLeft = quotesLeft - 1,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -722,11 +644,11 @@ private fun DisplayNoteFromTag(
if (canPreview && quotesLeft > 0) {
NoteCompose(
baseNote = baseNote,
accountViewModel = accountViewModel,
modifier = MaterialTheme.colorScheme.innerPostModifier,
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
} else {
@@ -20,15 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -37,22 +33,10 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
import java.util.Base64
@Composable
fun RobohashAsyncImage(
@@ -92,53 +76,34 @@ fun RobohashFallbackAsyncImage(
loadRobohash: Boolean,
) {
if (model != null && loadProfilePicture) {
val isBase64 = model.startsWith("data:image/jpeg;base64,")
if (isBase64) {
val context = LocalContext.current
val base64Painter =
rememberAsyncImagePainter(
model = Base64Requester.imageRequest(context, model),
val painter =
if (loadRobohash) {
rememberVectorPainter(
image = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight),
)
} else {
forwardingPainter(
painter =
rememberVectorPainter(
image = Icons.Default.Face,
),
colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter,
)
}
Image(
painter = base64Painter,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
colorFilter = colorFilter,
)
} else {
val painter =
if (loadRobohash) {
rememberVectorPainter(
image = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight),
)
} else {
forwardingPainter(
painter =
rememberVectorPainter(
image = Icons.Default.Face,
),
colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter,
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
} else {
if (loadRobohash) {
Image(
@@ -161,48 +126,3 @@ fun RobohashFallbackAsyncImage(
}
}
}
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}
@Stable
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val base64String = data.toString().removePrefix("data:image/jpeg;base64,")
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
if (bitmap == null) {
throw Exception("Unable to load base64 $base64String")
}
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher = Base64Fetcher(options, data)
}
}
@@ -97,7 +97,9 @@ fun SensitivityWarning(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit,
) {
val accountState = accountViewModel.account.showSensitiveContent.collectAsStateWithLifecycle()
val accountState =
accountViewModel.account.settings.showSensitiveContent
.collectAsStateWithLifecycle()
var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) }
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart
@@ -39,10 +40,13 @@ import androidx.compose.material3.SwipeToDismissBoxValue.Settled
import androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
@@ -69,6 +73,7 @@ fun SwipeToDeleteContainer(
}
return@rememberSwipeToDismissBoxState true
},
positionalThreshold = { it * .40f },
)
SwipeToDismissBox(
@@ -86,11 +91,19 @@ fun DismissBackground(dismissState: SwipeToDismissBoxState) {
if (dismissState.currentValue > dismissState.targetValue) {
Color(0xFFFF1744)
} else {
Color.Transparent
MaterialTheme.colorScheme.surfaceVariant
},
label = "DismissBackground",
)
val haptic = LocalHapticFeedback.current
LaunchedEffect(key1 = dismissState.currentValue > dismissState.targetValue) {
// doesn't run for the first time or when the list is updated.
if (dismissState.progress > 0 && dismissState.progress < 1) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
Row(
modifier =
Modifier
@@ -30,6 +30,7 @@ import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.VideoView
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
@@ -40,9 +41,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -217,6 +218,37 @@ fun VideoView(
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false,
) {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else if (gallery) {
MaterialTheme.colorScheme.videoGalleryModifier
} else {
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, isFiniteHeight, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
}
@Composable
fun VideoView(
videoUri: String,
mimeType: String?,
title: String? = null,
thumb: VideoThumb? = null,
borderModifier: Modifier,
isFiniteHeight: Boolean,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false,
) {
val defaultToStart by remember(videoUri) { mutableStateOf(DEFAULT_MUTED_SETTING.value) }
@@ -248,8 +280,7 @@ fun VideoView(
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
gallery = gallery,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
waveform = waveform,
artworkUri = artworkUri,
@@ -273,19 +304,12 @@ fun VideoView(
}
Box(modifier, contentAlignment = Alignment.Center) {
val image =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
// Always displays Blurharh to avoid size flickering
DisplayBlurHash(
blurhash,
null,
if (isFiniteHeight) ContentScale.FillWidth else ContentScale.FillWidth,
if (ratio != null) image.aspectRatio(ratio) else modifier,
if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
)
if (!automaticallyStartPlayback.value) {
@@ -302,7 +326,7 @@ fun VideoView(
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
waveform = waveform,
artworkUri = artworkUri,
@@ -326,9 +350,9 @@ fun VideoViewInner(
defaultToStart: Boolean = false,
title: String? = null,
thumb: VideoThumb? = null,
roundedCorner: Boolean,
gallery: Boolean = false,
showControls: Boolean = true,
isFiniteHeight: Boolean,
borderModifier: Modifier,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
@@ -338,7 +362,7 @@ fun VideoViewInner(
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
VideoPlayerActiveMutex(videoUri) { videoModifier, activeOnScreen ->
GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -351,15 +375,15 @@ fun VideoViewInner(
mimeType = mimeType,
controller = controller,
thumbData = thumb,
roundedCorner = roundedCorner,
gallery = gallery,
showControls = showControls,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = nostrUriCallback,
waveform = waveform,
keepPlaying = keepPlaying,
automaticallyStartPlayback = automaticallyStartPlayback,
activeOnScreen = activeOnScreen,
modifier = modifier,
borderModifier = borderModifier,
videoModifier = videoModifier,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
accountViewModel = accountViewModel,
@@ -655,9 +679,9 @@ fun VideoPlayerActiveMutex(
onDispose { trackingVideos.remove(myCache) }
}
val myModifier =
val videoModifier =
remember(videoUri) {
Modifier.fillMaxWidth().defaultMinSize(minHeight = 70.dp).onVisiblePositionChanges { distanceToCenter ->
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { distanceToCenter ->
myCache.distanceToCenter = distanceToCenter
if (distanceToCenter != null) {
@@ -684,7 +708,7 @@ fun VideoPlayerActiveMutex(
}
}
inner(myModifier, active)
inner(videoModifier, active)
}
@Stable
@@ -699,15 +723,15 @@ private fun RenderVideoPlayer(
mimeType: String?,
controller: MediaController,
thumbData: VideoThumb?,
roundedCorner: Boolean,
gallery: Boolean = false,
showControls: Boolean = true,
isFiniteHeight: Boolean,
nostrUriCallback: String?,
waveform: ImmutableList<Int>? = null,
keepPlaying: MutableState<Boolean>,
automaticallyStartPlayback: State<Boolean>,
activeOnScreen: MutableState<Boolean>,
modifier: Modifier,
borderModifier: Modifier,
videoModifier: Modifier,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)?,
accountViewModel: AccountViewModel,
@@ -716,27 +740,9 @@ private fun RenderVideoPlayer(
val controllerVisible = remember(controller) { mutableStateOf(false) }
Box {
val borders = MaterialTheme.colorScheme.imageModifier
val bordersSquare = MaterialTheme.colorScheme.videoGalleryModifier
val myModifier =
remember(controller) {
if (roundedCorner) {
modifier.then(
borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
)
} else if (gallery) {
Modifier
modifier.then(
bordersSquare.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
)
} else {
modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp).align(Alignment.Center)
}
}
Box(modifier = borderModifier) {
AndroidView(
modifier = myModifier,
modifier = videoModifier,
factory = { context: Context ->
PlayerView(context).apply {
player = controller
@@ -748,7 +754,7 @@ private fun RenderVideoPlayer(
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
useController = !gallery
useController = showControls
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
@@ -757,7 +763,7 @@ private fun RenderVideoPlayer(
} else {
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
if (!gallery) {
if (showControls) {
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
controller.pause()
@@ -776,7 +782,8 @@ private fun RenderVideoPlayer(
)
waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) }
if (!gallery) {
if (showControls) {
val startingMuteState = remember(controller) { controller.volume < 0.001 }
MuteButton(
@@ -824,7 +831,7 @@ private fun RenderVideoPlayer(
}
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, toggle)
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
}
} else {
controller.volume = 0f
@@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -388,6 +389,12 @@ private fun RenderImageOrVideo(
accountViewModel: AccountViewModel,
) {
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(true) }
val contentScale =
if (isFiniteHeight) {
ContentScale.Fit
} else {
ContentScale.FillWidth
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
if (content is MediaUrlImage) {
@@ -405,21 +412,28 @@ private fun RenderImageOrVideo(
UrlImageView(
content = content,
contentScale = contentScale,
mainImageModifier = mainModifier,
loadedImageModifier = Modifier.fillMaxWidth(),
isFiniteHeight = isFiniteHeight,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
)
} else if (content is MediaUrlVideo) {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
VideoViewInner(
videoUri = content.url,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
@@ -440,14 +454,21 @@ private fun RenderImageOrVideo(
LocalImageView(
content = content,
contentScale = contentScale,
mainImageModifier = mainModifier,
loadedImageModifier = Modifier.fillMaxWidth(),
isFiniteHeight = isFiniteHeight,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
)
} else if (content is MediaLocalVideo) {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
content.localFile?.let {
VideoViewInner(
videoUri = it.toUri().toString(),
@@ -455,7 +476,7 @@ private fun RenderImageOrVideo(
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
@@ -40,6 +40,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -79,8 +80,11 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils
import com.vitorpamplona.amethyst.ui.navigation.getActivity
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
@@ -94,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toHexKey
@@ -116,6 +121,19 @@ fun ZoomableContentView(
) {
var dialogOpen by remember(content) { mutableStateOf(false) }
val activity = LocalView.current.context.getActivity()
val currentWindowSize = currentWindowAdaptiveInfo().windowSizeClass
val isLandscapeMode = DeviceUtils.isLandscapeMetric(LocalContext.current)
val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode)
val contentScale =
if (isFiniteHeight) {
ContentScale.Fit
} else {
ContentScale.FillWidth
}
when (content) {
is MediaUrlImage ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
@@ -123,7 +141,7 @@ fun ZoomableContentView(
val mainImageModifier = Modifier.fillMaxWidth().clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
UrlImageView(content, mainImageModifier, loadedImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel)
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
}
is MediaUrlVideo ->
@@ -140,7 +158,12 @@ fun ZoomableContentView(
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
onDialog = {
dialogOpen = true
// if (!isFoldableOrLarge) {
// DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
// }
},
accountViewModel = accountViewModel,
)
}
@@ -150,7 +173,7 @@ fun ZoomableContentView(
val mainImageModifier = Modifier.fillMaxWidth().clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
LocalImageView(content, mainImageModifier, loadedImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel)
LocalImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
is MediaLocalVideo ->
content.localFile?.let {
@@ -172,7 +195,76 @@ fun ZoomableContentView(
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel)
ZoomableImageDialog(
content,
images,
onDismiss = {
dialogOpen = false
// if (!isFoldableOrLarge) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
},
accountViewModel,
)
}
}
@Composable
fun GalleryContentView(
content: BaseMediaContent,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
) {
when (content) {
is MediaUrlImage ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
TwoSecondController(content) { controllerVisible ->
val mainImageModifier = Modifier.fillMaxWidth()
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
UrlImageView(content, ContentScale.Crop, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
}
is MediaUrlVideo ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
VideoView(
videoUri = content.url,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
borderModifier = MaterialTheme.colorScheme.videoGalleryModifier,
authorName = content.authorName,
dimensions = content.dim,
blurhash = content.blurhash,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = content.uri,
accountViewModel = accountViewModel,
)
}
}
is MediaLocalImage ->
TwoSecondController(content) { controllerVisible ->
val mainImageModifier = Modifier.fillMaxWidth()
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
LocalImageView(content, ContentScale.Crop, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
is MediaLocalVideo ->
content.localFile?.let {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
VideoView(
videoUri = it.toUri().toString(),
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
borderModifier = MaterialTheme.colorScheme.videoGalleryModifier,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = content.uri,
accountViewModel = accountViewModel,
)
}
}
}
}
@@ -194,30 +286,24 @@ fun TwoSecondController(
@Composable
fun LocalImageView(
content: MediaLocalImage,
contentScale: ContentScale,
mainImageModifier: Modifier,
loadedImageModifier: Modifier,
isFiniteHeight: Boolean,
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false,
) {
if (content.localFileExists()) {
Box(contentAlignment = Alignment.Center) {
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
)
}
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
)
}
val contentScale =
remember {
if (isFiniteHeight) ContentScale.Fit else ContentScale.FillWidth
}
val ratio = remember(content) { aspectRatio(content.dim) }
if (showImage.value) {
val ratio = remember(content) { aspectRatio(content.dim) }
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it) {
SubcomposeAsyncImage(
model = content.localFile,
contentDescription = content.description,
@@ -298,28 +384,28 @@ fun LocalImageView(
@Composable
fun UrlImageView(
content: MediaUrlImage,
contentScale: ContentScale,
mainImageModifier: Modifier,
loadedImageModifier: Modifier,
isFiniteHeight: Boolean,
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false,
) {
Box(contentAlignment = Alignment.Center) {
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
)
}
val ratio = remember(content) { aspectRatio(content.dim) }
val ratio = remember(content) { aspectRatio(content.dim) }
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
)
}
if (showImage.value) {
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it) {
SubcomposeAsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = if (isFiniteHeight) ContentScale.Fit else ContentScale.FillWidth,
contentScale = contentScale,
modifier = mainImageModifier,
) {
when (painter.state) {
@@ -422,7 +508,7 @@ fun ImageUrlWithDownloadButton(
val inlineContent = mapOf("inlineContent" to InlineDownloadIcon(showImage))
val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } }
val pressIndicator = remember { Modifier.fillMaxWidth().clickable { runCatching { uri.openUri(url) } } }
Text(
text = annotatedTermsString,
@@ -605,6 +691,10 @@ fun ShareImageAction(
popupExpanded = popupExpanded,
videoUri = content.url,
postNostrUri = content.uri,
blurhash = content.blurhash,
dim = content.dim,
hash = content.hash,
mimeType = content.mimeType,
onDismiss = onDismiss,
)
} else if (content is MediaPreloadedContent) {
@@ -613,6 +703,10 @@ fun ShareImageAction(
popupExpanded = popupExpanded,
videoUri = content.localFile?.toUri().toString(),
postNostrUri = content.uri,
blurhash = content.blurhash,
dim = content.dim,
hash = null,
mimeType = content.mimeType,
onDismiss = onDismiss,
)
}
@@ -625,6 +719,10 @@ fun ShareImageAction(
popupExpanded: MutableState<Boolean>,
videoUri: String?,
postNostrUri: String?,
blurhash: String?,
dim: String?,
hash: String?,
mimeType: String?,
onDismiss: () -> Unit,
) {
DropdownMenu(
@@ -660,7 +758,7 @@ fun ShareImageAction(
if (videoUri != null) {
var n19 = Nip19Bech32.uriToRoute(postNostrUri)?.entity as? Nip19Bech32.NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay[0]) // TODO Whole list or first?
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay[0], blurhash, dim, hash, mimeType) // TODO Whole list or first?
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
}
}
@@ -162,8 +162,8 @@ class MarkdownMediaRenderer(
}
} else if (loadedLink?.nip19 != null) {
when (val entity = loadedLink.nip19.entity) {
is Nip19Bech32.NPub -> renderObservableUser(entity.hex, richTextStringBuilder)
is Nip19Bech32.NProfile -> renderObservableUser(entity.hex, richTextStringBuilder)
is Nip19Bech32.NPub -> renderObservableUser(entity.hex, loadedLink.nip19.nip19raw, richTextStringBuilder)
is Nip19Bech32.NProfile -> renderObservableUser(entity.hex, loadedLink.nip19.nip19raw, richTextStringBuilder)
is Nip19Bech32.Note -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
is Nip19Bech32.NEvent -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
is Nip19Bech32.NEmbed -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
@@ -201,10 +201,11 @@ class MarkdownMediaRenderer(
fun renderObservableUser(
userHex: String,
nip19: String,
richTextStringBuilder: RichTextString.Builder,
) {
renderInline(richTextStringBuilder) {
DisplayUser(userHex, null, accountViewModel, nav)
DisplayUser(userHex, nip19, null, accountViewModel, nav)
}
}
@@ -20,22 +20,41 @@
*/
package com.vitorpamplona.amethyst.ui.components.markdown
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.tooling.preview.Preview
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.material3.RichText
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@Composable
fun RenderContentAsMarkdown(
@@ -91,3 +110,348 @@ fun RenderContentAsMarkdown(
}
}
}
@Preview
@Composable
fun RenderContentAsMarkdownPreview() {
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
RenderContentAsMarkdown(
"# Hello 1\n" +
"## Hello 2\n" +
"### Hello 3\n" +
"#### Hello 4\n" +
"##### Hello 5\n" +
"###### Hello 6\n" +
"This is regular text\n" +
"\n" +
"**This is bold text**\n" +
"\n" +
"__This is bold text__\n" +
"\n" +
"*This is italic text*\n" +
"\n" +
"_This is italic text_\n" +
"\n" +
"~~Strikethrough~~\n" +
"\n" +
"\n" +
"## Blockquotes\n" +
"\n" +
"\n" +
"> Blockquotes can also be nested...\n" +
">> ...by using additional greater-than signs right next to each other...\n" +
"> > > ...or with spaces between arrows.\n" +
"\n" +
"\n" +
"## End\n",
tags = EmptyTagList,
canPreview = true,
quotesLeft = 2,
backgroundColor =
remember {
mutableStateOf(background)
},
callbackUri = "nostr:something",
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Preview
@Composable
fun RenderContentAsMarkdownListsPreview() {
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
RenderContentAsMarkdown(
"## Lists\n" +
"\n" +
"Unordered\n" +
"\n" +
"+ Create a list by starting a line with `+`, `-`, or `*`\n" +
"+ Sub-lists 2 spaces:\n" +
" - Character change forces new list:\n" +
" * Ac tristique libero volutpat at\n" +
" + Facilisis in pretium nisl aliquet\n" +
" - Nulla volutpat aliquam velit\n" +
"+ Very easy!\n" +
"\n" +
"Ordered\n" +
"\n" +
"1. Lorem ipsum dolor sit amet\n" +
"2. Consectetur adipiscing elit\n" +
"3. Integer molestie lorem at massa\n" +
"\n" +
"\n" +
"1. You can use sequential numbers...\n" +
"1. ...or keep all the numbers as `1.`\n" +
"\n" +
"Start number with offset:\n" +
"\n" +
"57. foo\n" +
"1. bar\n",
tags = EmptyTagList,
canPreview = true,
quotesLeft = 2,
backgroundColor =
remember {
mutableStateOf(background)
},
callbackUri = "nostr:something",
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Preview
@Composable
fun RenderContentAsMarkdownCodePreview() {
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
RenderContentAsMarkdown(
"## Code\n" +
"\n" +
"Inline `code`\n" +
"\n" +
"Indented code\n" +
"\n" +
" // Some comments\n" +
" line 1 of code\n" +
" line 2 of code\n" +
" line 3 of code\n" +
"\n" +
"\n" +
"Block code \"fences\"\n" +
"\n" +
"```\n" +
"Sample text here...\n" +
"```\n" +
"\n" +
"Syntax highlighting\n" +
"\n" +
"``` js\n" +
"var foo = function (bar) {\n" +
" return bar++;\n" +
"};\n" +
"var veryVeryVeryLongVariableNameWithALongValueAsWell = 123456789012345678901234567890;\n" +
"console.log(foo(5));\n" +
"```\n" +
"\n" +
"\n" +
"### End\n",
tags = EmptyTagList,
canPreview = true,
quotesLeft = 2,
backgroundColor =
remember {
mutableStateOf(background)
},
callbackUri = "nostr:something",
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Preview
@Composable
fun RenderContentAsMarkdownTablesPreview() {
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
RenderContentAsMarkdown(
"## Tables\n" +
"\n" +
"| Option | Description |\n" +
"| ------ | ----------- |\n" +
"| data | path to data files to supply the data that will be passed into templates. |\n" +
"| engine | engine to be used for processing templates. Handlebars is the default. |\n" +
"| ext | extension to be used for dest files. |\n" +
"\n" +
"Right aligned columns\n" +
"\n" +
"| Option | Description |\n" +
"| ------:| -----------:|\n" +
"| data | path to data files to supply the data that will be passed into templates. |\n" +
"| engine | engine to be used for processing templates. Handlebars is the default. |\n" +
"| ext | extension to be used for dest files. |\n",
tags = EmptyTagList,
canPreview = true,
quotesLeft = 2,
backgroundColor =
remember {
mutableStateOf(background)
},
callbackUri = "nostr:something",
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Preview
@Composable
fun RenderContentAsMarkdownFootNotesPreview() {
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
RenderContentAsMarkdown(
"### [Footnotes]\n" +
"\n" +
"Footnote 1 link[^first].\n" +
"\n" +
"Footnote 2 link[^second].\n" +
"\n" +
"Duplicated footnote reference[^second].\n" +
"\n" +
"[^first]: Footnote **can have markup**\n" +
"\n" +
" and multiple paragraphs.\n" +
"\n" +
"[^second]: Footnote text.\n",
tags = EmptyTagList,
canPreview = true,
quotesLeft = 2,
backgroundColor =
remember {
mutableStateOf(background)
},
callbackUri = "nostr:something",
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Preview
@Composable
fun RenderContentAsMarkdownUserPreview() {
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
runBlocking {
withContext(Dispatchers.IO) {
val qa =
MetadataEvent(
id = "4d5e1c614e18b915e6899fff93b5345253a5e2a8d0cbdb9ca98758da1612e05b",
pubKey = "88cc134b1a65f54ef48acc1df3665063d3ea45f04eab8af4646e561c5ae99079",
createdAt = 1722306330,
tags = emptyArray(),
content = "{\"lud16\":\"qa@primal.net\",\"website\":\"https:\\/\\/primal.net\",\"lud06\":\"\",\"name\":\"qauser\",\"banner\":\"https:\\/\\/m.primal.net\\/HQTd.jpg\",\"display_name\":\"qa\",\"nip05\":\"qa@primal.net\",\"picture\":\"https:\\/\\/m.primal.net\\/JidC.jpg\",\"about\":\"If you are following this account for quality content, you are going to have a bad time. #qa #primal \"}",
sig = "2a5fbd16e8fd67873cee1e60fc7b1b09d8a3cb5d503528507fa47d29669b259153ba710acce725d1f1bec867d7f86cc72820e496604e9183e5f58d0f2fb98f58",
)
UrlCachedPreviewer.cache.put(
"https://duckduckgo.com/",
UrlPreviewState.Loaded(
UrlInfoItem(
url = "https://duckduckgo.com/",
image = "https://duckduckgo.com/assets/logo_social-media.png",
title = "DuckDuckGo — Privacy, simplified.",
description = "The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.",
mimeType = "text/html".toMediaType(),
),
),
)
LocalCache.justConsume(qa, null)
}
}
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
RenderContentAsMarkdown(
"This is a test by nostr:npub13rxpxjc6vh65aay2eswlxejsv0f7530sf64c4arydetpckhfjpustsjeaf that tests multiple lines of markdown\n" +
"\n" +
"nostr:npub13rxpxjc6vh65aay2eswlxejsv0f7530sf64c4arydetpckhfjpustsjeaf\n" +
"\n" +
"here is a [link to somewhere in text](https://duckduckgo.com/) so as to see how it would render and here it is bare-bone https://duckduckgo.com/ as preview just to make sure it is nicely parsed.\n" +
"\n ## END \n",
tags = EmptyTagList,
canPreview = true,
quotesLeft = 2,
backgroundColor =
remember {
mutableStateOf(background)
},
callbackUri = "nostr:something",
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Preview
@Composable
fun RenderContentAsMarkdownNotePreview() {
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
runBlocking {
withContext(Dispatchers.IO) {
val blogPost =
LongTextNoteEvent(
id = "e69fa72a221d0b7362f8e63fd6ab84cc6b5e7b505fe49cefe6823a47a6a4b583",
pubKey = "88cc134b1a65f54ef48acc1df3665063d3ea45f04eab8af4646e561c5ae99079",
createdAt = 1718785725,
tags = arrayOf(arrayOf("d", "1718785686085"), arrayOf("title", "Test again"), arrayOf("summary", "Testing...."), arrayOf("t", "test"), arrayOf("t", "testing"), arrayOf("published_at", "1718785712"), arrayOf("alt", "This is a long form article, you can read it in https://habla.news/a/naddr1qvzqqqr4gupzpzxvzd935e04fm6g4nqa7dn9qc7nafzlqn4t3t6xgmjkr3dwnyreqqxnzde38qmnsdfk8qmrqwp405km0k"), arrayOf("p", "88cc134b1a65f54ef48acc1df3665063d3ea45f04eab8af4646e561c5ae99079"), arrayOf("e", "ab002ebdf49a06f9d94959d955c419964403339fde473ebd144321574a105811", "", "mention"), arrayOf("e", "466e2c6e5a59042b44799899885edd112074141e49cc50f9e2ead06f43bfc424", "", "mention"), arrayOf("e", "d6236a9108896ccf7882ce52cd8c53466d2c1018e7e85190f28310aa3dd5b554", "", "mention"), arrayOf("e", "c1896afdf528e2a62ab0fc15b8e0320ef58f10685363a9c6b2d549b79102f446", "", "mention")),
content = "lets try this out nostr:note14vqza005ngr0nk2ft8v4t3qejezqxvulmerna0g5gvs4wjsstqgsmeupgy and nostr:note1gehzcmj6tyzzk3renzvcshkazys8g9q7f8x9p70zatgx7salcsjqax60pz or nostr:note16c3k4ygg39kv77yzeefvmrzngekjcyqcul59ry8jsvg250w4k42qk9d4dw or nostr:note1cxyk4l049r32v24sls2m3cpjpm6c7yrg2d36n34j64ym0ygz73rqzplrn7 and some users nostr:npub13rxpxjc6vh65aay2eswlxejsv0f7530sf64c4arydetpckhfjpustsjeaf and nostr:npub13rxpxjc6vh65aay2eswlxejsv0f7530sf64c4arydetpckhfjpustsjeaf",
sig = "4c85e0eb0c46c5e3023431ad4ed8efa0abd66447ff757d246154e2349ac01ae0f88f213d02efa0a77f307f305d4a608c785ae1ca080c01cd3a9e7b8dffea6f9c",
)
LocalCache.justConsume(blogPost, null)
}
}
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
RenderContentAsMarkdown(
"here is a mention of another article nostr:naddr1qvzqqqr4gupzpzxvzd935e04fm6g4nqa7dn9qc7nafzlqn4t3t6xgmjkr3dwnyreqqxnzde38qmnsdfk8qmrqwp405km0k\n" +
"\n" +
"## Images\n" +
"\n" +
"![landscape](https://primal.b-cdn.net/media-cache?s=o&a=1&u=https%3A%2F%2Fm.primal.net%2FHUmC.jpg)\n" +
"\n" +
"and here is one in the middle of the text ![rose](https://primal.b-cdn.net/media-cache?s=o&a=1&u=https%3A%2F%2Fm.primal.net%2FHUmM.jpg) that continues right after. Just to see how this will render\n" +
"\n",
tags = EmptyTagList,
canPreview = true,
quotesLeft = 2,
backgroundColor =
remember {
mutableStateOf(background)
},
callbackUri = "nostr:something",
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -0,0 +1,86 @@
/**
* 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.ui.components.util
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.window.core.layout.WindowHeightSizeClass
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
object DeviceUtils {
/**
* Tries to determine if the device is
* in landscape mode, by using the [android.util.DisplayMetrics] API.
*
* Credits: NewPipe devs
*/
fun isLandscapeMetric(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels
fun changeDeviceOrientation(
isInLandscape: Boolean,
currentActivity: Activity,
) {
val newOrientation =
if (isInLandscape) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
currentActivity.requestedOrientation = newOrientation
}
/**
* This method looks at the window in which the app resides,
* and determines if it is large, while making sure not to be affected
* by configuration changes(such as screen rotation),
* as the device display metrics can be affected as well.
*
* It could be used as an approximation of the type of device(as is the case here),
* though one ought to be careful about multi-window situations.
*/
@Composable
fun windowIsLarge(
isInLandscapeMode: Boolean,
windowSize: WindowSizeClass,
): Boolean =
remember(windowSize) {
if (isInLandscapeMode) {
when (windowSize.windowHeightSizeClass) {
WindowHeightSizeClass.COMPACT -> false
WindowHeightSizeClass.MEDIUM -> true
WindowHeightSizeClass.EXPANDED -> true
else -> true
}
} else {
when (windowSize.windowWidthSizeClass) {
WindowWidthSizeClass.EXPANDED -> true
WindowWidthSizeClass.MEDIUM -> true
WindowWidthSizeClass.COMPACT -> false
else -> true
}
}
}
}
@@ -36,15 +36,15 @@ class BookmarkPrivateFeedFilter(
val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList()
val notes =
bookmarks.filterEvents(privateTags).mapNotNull { LocalCache.checkGetOrCreateNote(it) }
val addresses =
bookmarks.filterAddresses(privateTags).map { LocalCache.getOrCreateAddressableNote(it) }
return notes
.plus(addresses)
.toSet()
.sortedWith(DefaultFeedOrder)
return privateTags
.mapNotNull {
if (it.size > 1 && it[0] == "e") {
LocalCache.checkGetOrCreateNote(it[1])
} else if (it.size > 1 && it[0] == "a") {
LocalCache.checkGetOrCreateAddressableNote(it[1])
} else {
null
}
}.reversed()
}
}
@@ -32,14 +32,16 @@ class BookmarkPublicFeedFilter(
override fun feed(): List<Note> {
val bookmarks = account.userProfile().latestBookmarkList
val notes =
bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
val addresses =
bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes
.plus(addresses)
.toSet()
.sortedWith(DefaultFeedOrder)
return bookmarks
?.tags
?.mapNotNull {
if (it.size > 1 && it[0] == "e") {
LocalCache.checkGetOrCreateNote(it[1])
} else if (it.size > 1 && it[0] == "a") {
LocalCache.checkGetOrCreateAddressableNote(it[1])
} else {
null
}
}?.reversed() ?: emptyList()
}
}
@@ -21,10 +21,11 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.Card
import com.vitorpamplona.quartz.events.Event
val DefaultFeedOrder: Comparator<Note> =
compareBy<Note>(
compareByDescending<Note>
{
val noteEvent = it.event
if (noteEvent == null) {
@@ -36,8 +37,7 @@ val DefaultFeedOrder: Comparator<Note> =
null
}
}
},
{
it.idHex
},
).reversed()
}.thenBy { it.idHex }
val DefaultFeedOrderCard: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
@@ -32,12 +32,12 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
open class DiscoverChatFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean =
account.defaultDiscoveryFollowList.value ==
account.settings.defaultDiscoveryFollowList.value ==
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultDiscoveryFollowList.value ==
account.settings.defaultDiscoveryFollowList.value ==
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
override fun feed(): List<Note> {
@@ -67,7 +67,7 @@ open class DiscoverChatFeedFilter(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultDiscoveryFollowList.value,
selectedListName = account.settings.defaultDiscoveryFollowList.value,
followLists = account.liveDiscoveryFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
@@ -32,19 +32,19 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
open class DiscoverCommunityFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean =
account.defaultDiscoveryFollowList.value ==
account.settings.defaultDiscoveryFollowList.value ==
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultDiscoveryFollowList.value ==
account.settings.defaultDiscoveryFollowList.value ==
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
override fun feed(): List<Note> {
val filterParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultDiscoveryFollowList.value,
selectedListName = account.settings.defaultDiscoveryFollowList.value,
followLists = account.liveDiscoveryFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
@@ -73,7 +73,7 @@ open class DiscoverCommunityFeedFilter(
val filterParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultDiscoveryFollowList.value,
selectedListName = account.settings.defaultDiscoveryFollowList.value,
followLists = account.liveDiscoveryFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
@@ -36,7 +36,7 @@ open class DiscoverLiveFeedFilter(
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
open fun followList(): String = account.defaultDiscoveryFollowList.value
open fun followList(): String = account.settings.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean =
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
@@ -57,7 +57,7 @@ open class DiscoverLiveFeedFilter(
val filterParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultDiscoveryFollowList.value,
selectedListName = account.settings.defaultDiscoveryFollowList.value,
followLists = account.liveDiscoveryFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
@@ -32,7 +32,7 @@ open class DiscoverMarketplaceFeedFilter(
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
open fun followList(): String = account.defaultDiscoveryFollowList.value
open fun followList(): String = account.settings.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean =
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
@@ -55,7 +55,7 @@ open class DiscoverMarketplaceFeedFilter(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.userProfile().pubkeyHex,
account.defaultDiscoveryFollowList.value,
account.settings.defaultDiscoveryFollowList.value,
account.liveDiscoveryFollowLists.value,
account.flowHiddenUsers.value,
)
@@ -36,7 +36,7 @@ open class DiscoverNIP89FeedFilter(
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
open fun followList(): String = account.defaultDiscoveryFollowList.value
open fun followList(): String = account.settings.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean =
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
@@ -58,7 +58,7 @@ open class DiscoverNIP89FeedFilter(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.userProfile().pubkeyHex,
account.defaultDiscoveryFollowList.value,
account.settings.defaultDiscoveryFollowList.value,
account.liveDiscoveryFollowLists.value,
account.flowHiddenUsers.value,
)
@@ -33,11 +33,11 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
class HomeConversationsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
override fun showHiddenKey(): Boolean =
account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
@@ -54,7 +54,7 @@ class HomeConversationsFeedFilter(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultHomeFollowList.value,
selectedListName = account.settings.defaultHomeFollowList.value,
followLists = account.liveHomeFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
@@ -39,16 +39,16 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
class HomeNewThreadFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
override fun showHiddenKey(): Boolean =
account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultHomeFollowList.value,
selectedListName = account.settings.defaultHomeFollowList.value,
followLists = account.liveHomeFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
@@ -37,7 +37,7 @@ open class NIP90ContentDiscoveryResponseFilter(
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + request
open fun followList(): String = account.defaultDiscoveryFollowList.value
open fun followList(): String = account.settings.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean =
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
@@ -71,7 +71,7 @@ open class NIP90ContentDiscoveryResponseFilter(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.userProfile().pubkeyHex,
account.defaultDiscoveryFollowList.value,
account.settings.defaultDiscoveryFollowList.value,
account.liveDiscoveryFollowLists.value,
account.flowHiddenUsers.value,
)
@@ -47,18 +47,18 @@ import com.vitorpamplona.quartz.events.RepostEvent
class NotificationFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList.value
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultNotificationFollowList.value
override fun showHiddenKey(): Boolean =
account.defaultNotificationFollowList.value ==
account.settings.defaultNotificationFollowList.value ==
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultNotificationFollowList.value ==
account.settings.defaultNotificationFollowList.value ==
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultNotificationFollowList.value,
selectedListName = account.settings.defaultNotificationFollowList.value,
followLists = account.liveNotificationFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
class UserProfileConversationsFeedFilter(
val user: User,
@@ -59,7 +60,8 @@ class UserProfileConversationsFeedFilter(
it.event is TextNoteEvent ||
it.event is PollNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is LiveActivitiesChatMessageEvent
it.event is LiveActivitiesChatMessageEvent ||
it.event is TorrentCommentEvent
) &&
!it.isNewThread() &&
account.isAcceptable(it)
@@ -35,8 +35,8 @@ class UserProfileGalleryFeedFilter(
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + "ProfileGallery"
override fun showHiddenKey(): Boolean =
account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
override fun feed(): List<Note> {
val params = buildFilterParams(account)
@@ -47,16 +47,7 @@ class UserProfileGalleryFeedFilter(
}
var sorted = sort(notes)
var finalnotes = setOf<Note>()
for (item in sorted) {
val note = (item.event as ProfileGalleryEntryEvent).event()?.let { LocalCache.checkGetOrCreateNote(it) }
if (note != null) {
note.associatedNote = item
finalnotes = finalnotes + note
}
}
return finalnotes.toList()
return sorted.toList()
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
@@ -74,7 +65,7 @@ class UserProfileGalleryFeedFilter(
): Boolean {
val noteEvent = it.event
return (
(it.event?.pubKey() == user.pubkeyHex && noteEvent is ProfileGalleryEntryEvent) && noteEvent.hasUrl() && noteEvent.hasEvent() // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
(it.event?.pubKey() == user.pubkeyHex && noteEvent is ProfileGalleryEntryEvent) && noteEvent.hasUrl() && noteEvent.hasFromEvent() // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) &&
params.match(noteEvent) &&
account.isAcceptable(it)
@@ -83,7 +74,7 @@ class UserProfileGalleryFeedFilter(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultStoriesFollowList.value,
selectedListName = account.settings.defaultStoriesFollowList.value,
followLists = account.liveStoriesFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
class UserProfileNewThreadFeedFilter(
@@ -73,7 +74,8 @@ class UserProfileNewThreadFeedFilter(
it.event is PollNoteEvent ||
it.event is HighlightEvent ||
it.event is AudioTrackEvent ||
it.event is AudioHeaderEvent
it.event is AudioHeaderEvent ||
it.event is TorrentEvent
) &&
it.isNewThread() &&
account.isAcceptable(it)
@@ -20,16 +20,30 @@
*/
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
import com.vitorpamplona.quartz.events.zaps.UserZaps
import com.vitorpamplona.quartz.events.LnZapEventInterface
class UserProfileZapsFeedFilter(
val user: User,
) : FeedFilter<ZapReqResponse>() {
override fun feedKey(): String = user.pubkeyHex
override fun feed(): List<ZapReqResponse> = UserZaps.forProfileFeed(user.zaps)
override fun feed(): List<ZapReqResponse> = forProfileFeed(user.zaps)
override fun limit() = 400
companion object {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
if (zaps == null) return emptyList()
return (
zaps
.mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } }
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
.reversed()
)
}
}
}
@@ -34,11 +34,11 @@ import com.vitorpamplona.quartz.events.VideoVerticalEvent
class VideoFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList.value
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value
override fun showHiddenKey(): Boolean =
account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
override fun feed(): List<Note> {
val params = buildFilterParams(account)
@@ -78,7 +78,7 @@ class VideoFeedFilter(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultStoriesFollowList.value,
selectedListName = account.settings.defaultStoriesFollowList.value,
followLists = account.liveStoriesFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
@@ -0,0 +1,194 @@
/**
* 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.ui.feeds
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.events.DeletionEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@Stable
class FeedContentState(
val localFilter: FeedFilter<Note>,
val viewModelScope: CoroutineScope,
) : InvalidatableContent {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()
// Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
private var lastFeedKey: String? = null
fun sendToTop() {
if (scrolltoTopPending) return
scrolltoTopPending = true
viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) }
}
suspend fun sentToTop() {
scrolltoTopPending = false
}
private fun refresh() {
viewModelScope.launch(Dispatchers.Default) { refreshSuspended() }
}
fun refreshSuspended() {
checkNotInMainThread()
lastFeedKey = localFilter.feedKey()
val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList()
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
if (!equalImmutableLists(notes, oldNotesState.feed.value.list)) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
}
private fun updateFeed(notes: ImmutableList<Note>) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.tryEmit(FeedState.Empty)
} else if (currentState is FeedState.Loaded) {
currentState.feed.tryEmit(LoadedFeedState(notes, localFilter.showHiddenKey()))
} else {
_feedContent.tryEmit(
FeedState.Loaded(MutableStateFlow(LoadedFeedState(notes, localFilter.showHiddenKey()))),
)
}
}
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) {
if (oldNotesState is FeedState.Loaded) {
val deletionEvents: List<DeletionEvent> =
newItems.mapNotNull {
val noteEvent = it.event
if (noteEvent is DeletionEvent) noteEvent else null
}
val oldList =
if (deletionEvents.isEmpty()) {
oldNotesState.feed.value.list
} else {
val deletedEventIds = deletionEvents.flatMapTo(HashSet()) { it.deleteEvents() }
val deletedEventAddresses = deletionEvents.flatMapTo(HashSet()) { it.deleteAddresses() }
oldNotesState.feed.value.list
.filter { !it.wasOrShouldBeDeletedBy(deletedEventIds, deletedEventAddresses) }
.toImmutableList()
}
val newList =
localFilter
.updateListWith(oldList, newItems)
.distinctBy { it.idHex }
.toImmutableList()
if (!equalImmutableLists(newList, oldNotesState.feed.value.list)) {
updateFeed(newList)
}
} else if (oldNotesState is FeedState.Empty) {
val newList =
localFilter
.updateListWith(emptyList(), newItems)
.distinctBy { it.idHex }
.toImmutableList()
if (newList.isNotEmpty()) {
updateFeed(newList)
}
} else {
// Refresh Everything
refreshSuspended()
}
} else {
// Refresh Everything
refreshSuspended()
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
override fun invalidateData(ignoreIfDoing: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
}
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
}
}
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) { refreshFromOldState(it.flatten().toSet()) }
}
fun updateFeedWith(newNotes: Set<Note>) {
if (
localFilter is AdditiveFeedFilter &&
(_feedContent.value is FeedState.Loaded || _feedContent.value is FeedState.Empty)
) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
fun destroy() {
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
bundlerInsert.cancel()
bundler.cancel()
}
}

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